mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-22 10:32:23 +01:00
Merge branch 'master' into users/archie/embedding-configuration
This commit is contained in:
@@ -1 +1 @@
|
||||
[Preview this branch](https://dataexplorer-preview.azurewebsites.net/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true)
|
||||
[Preview this branch](https://dataexplorer-preview.portal.cosmos.azure.com/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true)
|
||||
|
||||
@@ -7,3 +7,12 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ["*"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
+42
-42
@@ -19,11 +19,11 @@ jobs:
|
||||
name: "Log Code Metrics"
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: node utils/codeMetrics.js
|
||||
env:
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Compile TypeScript"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: npm run compile
|
||||
- run: npm run compile:strict
|
||||
@@ -44,48 +44,48 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Check Format"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: npm run format:check
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Lint"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
unittest:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Unit Tests"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: npm run test
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Build"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: npm run build:contracts
|
||||
- name: Restore Build Cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .cache
|
||||
key: ${{ runner.os }}-build-cache
|
||||
@@ -94,12 +94,12 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- run: cp -r ./Contracts ./dist/contracts
|
||||
- run: cp -r ./configs ./dist/configs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: "Az CLI login"
|
||||
uses: azure/login@v1
|
||||
uses: azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1
|
||||
with:
|
||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
|
||||
steps:
|
||||
- name: Download Dist Folder
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: dist
|
||||
- run: cp ./configs/prod.json config.json
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
|
||||
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
|
||||
- run: dotnet nuget remove source "ADO"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
name: Upload package to Artifacts
|
||||
with:
|
||||
name: prod-package
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
|
||||
steps:
|
||||
- name: Download Dist Folder
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: dist
|
||||
- run: cp ./configs/mpac.json config.json
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
|
||||
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
|
||||
- run: dotnet nuget remove source "ADO"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
name: Upload package to Artifacts
|
||||
with:
|
||||
name: mpac-package
|
||||
@@ -172,22 +172,22 @@ jobs:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||
shardTotal: [20]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- name: "Az CLI login"
|
||||
uses: Azure/login@v2
|
||||
uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||
with:
|
||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
||||
# We can't use MSAL within playwright so we acquire tokens prior to running the tests
|
||||
- name: "Acquire RBAC tokens for test accounts"
|
||||
uses: azure/cli@v2
|
||||
uses: azure/cli@9f7ce6f37c31b777ec6c6b6d1dfe7db79f497956 # v2.2.0
|
||||
env:
|
||||
DE_ACCOUNT_PREFIX: ${{ secrets.E2ETESTS_ACCOUNT_PREFIX }}
|
||||
DE_TEST_RESOURCE_GROUP: ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
||||
- name: "Re-auth for upload (refresh OIDC token)"
|
||||
if: ${{ !cancelled() }}
|
||||
uses: Azure/login@v2
|
||||
uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||
with:
|
||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||
@@ -262,15 +262,15 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22.x
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: "Az CLI login"
|
||||
uses: Azure/login@v2
|
||||
uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||
with:
|
||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||
|
||||
@@ -23,17 +23,17 @@ jobs:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
||||
E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: "Az CLI login"
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||
with:
|
||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
- run: npm ci
|
||||
- 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
|
||||
|
||||
- Install Node.js 22.x.
|
||||
- `npm install`
|
||||
- `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
|
||||
retired. The goal is to remove all notebooks/Phoenix/Juno/GitHub-for-notebooks code,
|
||||
dependencies, UI surfaces, telemetry, and configuration, while **preserving the
|
||||
database shell terminals** (Mongo / Cassandra / Postgres / VCoreMongo), which today
|
||||
database shell terminals** (Mongo / Cassandra / Postgres / VCoreMongo / Cosmos DB NoSQL), which today
|
||||
share the Terminal infrastructure with notebooks.
|
||||
|
||||
This document is the implementation plan only. No code changes are made here.
|
||||
|
||||
## Scope decisions (confirmed)
|
||||
|
||||
- **Database shells**: Keep the Mongo/Cassandra/Postgres/VCoreMongo shells, but migrate
|
||||
- **Database shells**: Keep the Mongo/Cassandra/Postgres/VCoreMongo/Cosmos DB NoSQL shells, but migrate
|
||||
them to use the **CloudShell** path exclusively. Remove the legacy Phoenix
|
||||
notebook-server shell path.
|
||||
- **GitHub integration**: Remove entirely (it exists only for notebook pinned repos).
|
||||
@@ -108,7 +108,7 @@ This is a continuation of an in-progress removal effort. Reference commits:
|
||||
Each phase is independently buildable and shippable. Within each phase, **all
|
||||
references to removed code are also removed** so the tree compiles. After every phase
|
||||
run: `npm run compile`, `npm run compile:strict`, `npm run lint`, `npm run format:check`,
|
||||
`npm test`, and a webpack build (`npm run build:ci`); manually verify the four shells
|
||||
`npm test`, and a webpack build (`npm run build:ci`); manually verify the five shells
|
||||
still open.
|
||||
|
||||
### Phase 1 — Decouple database shells to CloudShell-only ✅ COMPLETED
|
||||
@@ -235,7 +235,28 @@ engine deleted there. No separate work remains for this phase.
|
||||
pinned-repo methods.
|
||||
- Remove GitHub-related localization keys from **all** locale files (`en` + non-English).
|
||||
|
||||
### Phase 5 — Remove Phoenix and the notebook container/allocation core
|
||||
### Phase 5 — Remove Phoenix and the notebook container/allocation core ✅ COMPLETED
|
||||
> **Status:** Implemented on branch `users/jawelton/remove-notebooks-phase5-081426`.
|
||||
> The automated verification sweep is green: `compile`, `compile:strict`, `lint`,
|
||||
> `format:check`, `test` (1931 passing, 5 skipped), and `build:ci` (webpack) all pass.
|
||||
>
|
||||
> **Implementation notes / deviations:**
|
||||
> - Removed the Phoenix-only connection status and memory tracker command-bar UI, which
|
||||
> depended entirely on the deleted `useNotebook` container state.
|
||||
> - Moved the unrelated `isSynapseLinkUpdating` flag from `useNotebook` into the existing
|
||||
> `useCommandBar` Zustand store and made static command creation react to it.
|
||||
> - Finished the deferred Phase 1 cleanup by replacing remaining mixed
|
||||
> `isShellEnabled`/notebook availability checks with the `enableCloudShell` feature gate.
|
||||
> The non-Phoenix Mongo fallback remains available when CloudShell is disabled.
|
||||
> - Removed the remaining direct nteract/runtime dependencies with no surviving importers:
|
||||
> `@nteract/commutable`, `@nteract/core`, `@nteract/fixtures`, `@nteract/myths`, and
|
||||
> `rx-jupyter`. Added `@types/uuid` explicitly because surviving CloudShell/UserContext
|
||||
> code had previously received UUID declarations through the removed transitive graph.
|
||||
> - Preserved the newer Cosmos DB (NoSQL) Cloud Shell added after this roadmap was written;
|
||||
> all terminal tabs still instantiate `CloudShellTerminalComponentAdapter`.
|
||||
> - Residual notebook/Phoenix config, contracts, telemetry, localization, Juno, generated
|
||||
> ARM clients, and images remain intentionally deferred to Phase 6.
|
||||
|
||||
- Delete `src/Phoenix/`, `src/Explorer/Notebook/NotebookContainerClient.ts`,
|
||||
`src/Explorer/Notebook/NotebookManager.tsx`, `src/Explorer/Notebook/useNotebook.ts`,
|
||||
`src/Explorer/Notebook/NotebookContentItem.ts`, `src/Explorer/Notebook/NotebookUtil.ts`
|
||||
@@ -279,7 +300,7 @@ npm run format:check
|
||||
npm test
|
||||
npm run build:ci
|
||||
```
|
||||
Plus manual smoke test: open Mongo, Cassandra, Postgres, and VCoreMongo shells.
|
||||
Plus manual smoke test: open Mongo, Cassandra, Postgres, VCoreMongo, and Cosmos DB NoSQL shells.
|
||||
|
||||
## Notes & considerations
|
||||
|
||||
|
||||
Generated
+211
-721
File diff suppressed because it is too large
Load Diff
+8
-9
@@ -3,6 +3,9 @@
|
||||
"version": "1.0.0",
|
||||
"description": "Cosmos Explorer",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/arm-cosmosdb": "16.4.0",
|
||||
"@azure/cosmos": "4.7.0",
|
||||
@@ -14,18 +17,14 @@
|
||||
"@fluentui/react": "8.119.0",
|
||||
"@fluentui/react-components": "9.54.2",
|
||||
"@microsoft/applicationinsights-web": "2.6.1",
|
||||
"@nteract/commutable": "7.5.1",
|
||||
"@nteract/core": "15.1.9",
|
||||
"@nteract/fixtures": "2.3.0",
|
||||
"@nteract/markdown": "4.6.0",
|
||||
"@nteract/myths": "0.1.9",
|
||||
"@octokit/request": "8.4.1",
|
||||
"@octokit/rest": "17.9.2",
|
||||
"@testing-library/jest-dom": "6.4.6",
|
||||
"@types/lodash": "4.14.171",
|
||||
"@types/mkdirp": "1.0.1",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"allotment": "1.20.2",
|
||||
@@ -34,7 +33,7 @@
|
||||
"canvas": "3.2.1",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"clipboard-copy": "4.0.1",
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"crossroads": "0.12.2",
|
||||
"css-element-queries": "1.1.1",
|
||||
"d3": "7.9.0",
|
||||
@@ -87,11 +86,10 @@
|
||||
"react-window": "1.8.10",
|
||||
"react-youtube": "9.0.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rx-jupyter": "5.5.12",
|
||||
"shell-quote": "1.10.0",
|
||||
"styled-components": "5.0.1",
|
||||
"swr": "0.4.0",
|
||||
"terser-webpack-plugin": "5.3.9",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"tinykeys": "2.1.0",
|
||||
"underscore": "1.13.8",
|
||||
"utility-types": "3.10.0",
|
||||
@@ -136,7 +134,7 @@
|
||||
"@types/hasher": "0.0.31",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/node": "18.19.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/post-robot": "10.0.1",
|
||||
"@types/q": "1.5.1",
|
||||
"@types/react": "17.0.44",
|
||||
@@ -149,6 +147,7 @@
|
||||
"@types/sinon": "2.3.3",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"@types/underscore": "1.7.36",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@types/youtube-player": "5.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
"@typescript-eslint/parser": "6.7.4",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
[defaults]
|
||||
group = dataexplorer-preview
|
||||
sku = P1v2
|
||||
appserviceplan = dataexplorer-preview
|
||||
location = westus2
|
||||
web = dataexplorer-preview
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ Cosmos Explorer Preview makes it possible to try a working version of any commit
|
||||
|
||||
Initial support is for Hosted (Connection string only) or the Azure Portal. Examples:
|
||||
|
||||
Connection string URLs: https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/hostedExplorer.html
|
||||
Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/explorer.html#home
|
||||
Connection string URLs: https://dataexplorer-preview.portal.cosmos.azure.com/commit/COMMIT_SHA/hostedExplorer.html
|
||||
Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.portal.cosmos.azure.com/commit/COMMIT_SHA/explorer.html#home
|
||||
|
||||
In both cases replace `COMMIT_SHA` with the commit you want to view. It must have already completed its build on GitHub Actions.
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"PROXY_PATH": "/proxy",
|
||||
"msalRedirectURI": "https://dataexplorer-preview.azurewebsites.net/"
|
||||
"msalRedirectURI": "https://dataexplorer-preview.portal.cosmos.azure.com/"
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ const port = process.env.PORT || 3000;
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
||||
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
|
||||
const previewSiteEndpoint = "https://dataexplorer-preview.portal.cosmos.azure.com";
|
||||
const previewStorageWebsiteEndpoint = "_REPLACE_STORAGE_WEBSITE_ENDPOINT_";
|
||||
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
|
||||
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
export class EndpointsRegex {
|
||||
public static readonly cassandra = [
|
||||
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
|
||||
"HostName=(.*).cassandra.cosmos.azure.com",
|
||||
];
|
||||
public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com";
|
||||
public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com";
|
||||
public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com";
|
||||
public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com";
|
||||
}
|
||||
|
||||
export class ApiEndpoints {
|
||||
public static runtimeProxy: string = "/api/RuntimeProxy";
|
||||
public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy";
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
jest.mock("../../Utils/arm/request");
|
||||
jest.mock("../CosmosClient");
|
||||
jest.mock("../Logger");
|
||||
jest.mock("../ErrorHandlingUtils", () => ({ handleError: jest.fn() }));
|
||||
jest.mock("../../Utils/NotificationConsoleUtils");
|
||||
import * as Logger from "Common/Logger";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { DatabaseAccount } from "../../Contracts/DataModels";
|
||||
import { updateUserContext } from "../../UserContext";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { armRequest } from "../../Utils/arm/request";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
import { readCollections } from "./readCollections";
|
||||
|
||||
describe("readCollections", () => {
|
||||
const clearMessage = jest.fn();
|
||||
const fetchAll = jest.fn();
|
||||
const readAll = jest.fn(() => ({ fetchAll }));
|
||||
const database = jest.fn(() => ({ containers: { readAll } }));
|
||||
const diagnostics = {
|
||||
clientSideRequestStatistics: {
|
||||
requestDurationInMs: 123,
|
||||
locationEndpointsContacted: ["https://test.documents.azure.com"],
|
||||
retryDiagnostics: { failedAttempts: [{ statusCode: 429 }] },
|
||||
},
|
||||
diagnosticNode: { data: { responsePayload: "not-for-logging" } },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(logConsoleProgress as jest.Mock).mockReturnValue(clearMessage);
|
||||
(client as jest.Mock).mockReturnValue({ database });
|
||||
updateUserContext({ authType: AuthType.MasterKey });
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
@@ -23,26 +49,61 @@ describe("readCollections", () => {
|
||||
});
|
||||
await readCollections("database");
|
||||
expect(armRequest).toHaveBeenCalled();
|
||||
expect(client).not.toHaveBeenCalled();
|
||||
expect(clearMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call SDK if not logged in with non-AAD method", async () => {
|
||||
updateUserContext({
|
||||
authType: AuthType.MasterKey,
|
||||
it("should log SDK request statistics and return collections for non-AAD authentication", async () => {
|
||||
const resources = [{ id: "container" }];
|
||||
fetchAll.mockResolvedValue({ resources, diagnostics });
|
||||
|
||||
await expect(readCollections("database")).resolves.toBe(resources);
|
||||
|
||||
expect(database).toHaveBeenCalledWith("database");
|
||||
expect(readAll).toHaveBeenCalledWith();
|
||||
expect(fetchAll).toHaveBeenCalledTimes(1);
|
||||
expect(Logger.logInfo).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining(`diagnostics=${JSON.stringify(diagnostics.clientSideRequestStatistics)}`),
|
||||
"readCollections",
|
||||
);
|
||||
expect(JSON.stringify(jest.mocked(Logger.logInfo).mock.calls)).not.toContain("not-for-logging");
|
||||
expect(clearMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should log SDK failure statistics and rethrow the original error", async () => {
|
||||
const error = Object.assign(new Error("fetchAll failed"), { diagnostics });
|
||||
fetchAll.mockRejectedValue(error);
|
||||
|
||||
await expect(readCollections("database")).rejects.toBe(error);
|
||||
|
||||
expect(Logger.logError).toHaveBeenCalledWith(
|
||||
`readCollections: fetchAll failed for database database, diagnostics=${JSON.stringify(
|
||||
diagnostics.clientSideRequestStatistics,
|
||||
)}`,
|
||||
"readCollections",
|
||||
);
|
||||
expect(handleError).toHaveBeenCalledWith(
|
||||
error,
|
||||
"ReadCollections",
|
||||
"Error while querying containers for database database",
|
||||
);
|
||||
expect(clearMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should preserve error handling when diagnostics are unavailable", async () => {
|
||||
const error = new Error("client initialization failed");
|
||||
(client as jest.Mock).mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
(client as jest.Mock).mockReturnValue({
|
||||
database: () => {
|
||||
return {
|
||||
containers: {
|
||||
readAll: () => {
|
||||
return {
|
||||
fetchAll: (): unknown => [],
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await readCollections("database");
|
||||
expect(client).toHaveBeenCalled();
|
||||
|
||||
await expect(readCollections("database")).rejects.toBe(error);
|
||||
|
||||
expect(Logger.logError).not.toHaveBeenCalled();
|
||||
expect(handleError).toHaveBeenCalledWith(
|
||||
error,
|
||||
"ReadCollections",
|
||||
"Error while querying containers for database database",
|
||||
);
|
||||
expect(clearMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +78,9 @@ export async function readCollections(databaseId: string): Promise<DataModels.Co
|
||||
const sdkResponse = await client().database(databaseId).containers.readAll().fetchAll();
|
||||
Logger.logInfo(
|
||||
`readCollections: fetchAll completed for database ${databaseId}, count=${sdkResponse.resources
|
||||
?.length}, durationMs=${Date.now() - fetchAllStart}`,
|
||||
?.length}, durationMs=${Date.now() - fetchAllStart}, diagnostics=${JSON.stringify(
|
||||
sdkResponse.diagnostics?.clientSideRequestStatistics,
|
||||
)}`,
|
||||
"readCollections",
|
||||
);
|
||||
traceSuccess(
|
||||
@@ -88,6 +90,15 @@ export async function readCollections(databaseId: string): Promise<DataModels.Co
|
||||
);
|
||||
return sdkResponse.resources as DataModels.Collection[];
|
||||
} catch (error) {
|
||||
const diagnostics = error instanceof Error && "diagnostics" in error ? error.diagnostics : undefined;
|
||||
if (diagnostics && typeof diagnostics === "object" && "clientSideRequestStatistics" in diagnostics) {
|
||||
Logger.logError(
|
||||
`readCollections: fetchAll failed for database ${databaseId}, diagnostics=${JSON.stringify(
|
||||
diagnostics.clientSideRequestStatistics,
|
||||
)}`,
|
||||
"readCollections",
|
||||
);
|
||||
}
|
||||
traceFailure(Action.ReadCollections, { databaseId, error: error?.message }, startKey);
|
||||
handleError(error, "ReadCollections", `Error while querying containers for database ${databaseId}`);
|
||||
throw error;
|
||||
|
||||
+34
-1
@@ -30,6 +30,13 @@ export interface ConfigContext {
|
||||
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
|
||||
allowedMongoProxyEndpoints: 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;
|
||||
proxyPath?: string;
|
||||
AAD_ENDPOINT: string;
|
||||
@@ -73,7 +80,7 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
|
||||
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
|
||||
`^https:\\/\\/.*\\.powerbi\\.com$`,
|
||||
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
|
||||
`^https:\\/\\/dataexplorer-preview\\.portal\\.cosmos\\.azure\\.com$`,
|
||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.fr$`,
|
||||
`^https:\\/\\/portal\\.sovcloud-azure\\.fr$`,
|
||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.de$`,
|
||||
@@ -81,6 +88,32 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
|
||||
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
|
||||
], // Webpack injects this at build time
|
||||
SQL_DNS_ZONES: [
|
||||
"documents.azure.com",
|
||||
"sql.cosmosdb.azure.com",
|
||||
"sql.cosmos.azure.com",
|
||||
"sqlx.cosmos.azure.com",
|
||||
"documents-staging.windows-ppe.net",
|
||||
"sql.cosmosdb.windows-ppe.net",
|
||||
"sql.cosmos.windows-ppe.net",
|
||||
"sqlx.cosmos.windows-ppe.net",
|
||||
],
|
||||
MONGO_DNS_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"],
|
||||
MONGO_COMPUTE_DNS_ZONES: ["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"],
|
||||
CASSANDRA_DNS_ZONES: [
|
||||
"cassandra.cosmosdb.azure.com",
|
||||
"cassandra.cosmos.azure.com",
|
||||
"cassandra.cosmosdb.windows-ppe.net",
|
||||
"cassandra.cosmos.windows-ppe.net",
|
||||
],
|
||||
TABLE_DNS_ZONES: [
|
||||
"table.cosmosdb.azure.com",
|
||||
"table.cosmos.azure.com",
|
||||
"table.cosmosdb.windows-ppe.net",
|
||||
"table.cosmos.windows-ppe.net",
|
||||
],
|
||||
DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"],
|
||||
GREMLIN_ENDPOINT_ZONES: ["gremlin.cosmos.azure.com", "gremlin.cosmos.windows-ppe.net"],
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
AAD_ENDPOINT: "https://login.microsoftonline.com/",
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface DatabaseAccountBackupPolicy {
|
||||
}
|
||||
|
||||
export interface DatabaseAccountExtendedProperties {
|
||||
apiProperties?: DatabaseAccountApiProperties;
|
||||
documentEndpoint?: string;
|
||||
disableLocalAuth?: boolean;
|
||||
tableEndpoint?: string;
|
||||
@@ -70,6 +71,10 @@ export interface DatabaseAccountExtendedProperties {
|
||||
enableEmbeddingGenerator?: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountApiProperties {
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountResponseLocation {
|
||||
documentEndpoint: string;
|
||||
failoverPriority: number;
|
||||
|
||||
@@ -28,7 +28,6 @@ import { userContext } from "../UserContext";
|
||||
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
|
||||
import { useSidePanel } from "../hooks/useSidePanel";
|
||||
import Explorer from "./Explorer";
|
||||
import { useNotebook } from "./Notebook/useNotebook";
|
||||
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";
|
||||
import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel";
|
||||
import StoredProcedure from "./Tree/StoredProcedure";
|
||||
@@ -120,23 +119,17 @@ export const createCollectionContextMenuButton = (
|
||||
iconSrc: HostedTerminalIcon,
|
||||
onClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
|
||||
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
|
||||
if (userContext.features.enableCloudShell) {
|
||||
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
|
||||
} else {
|
||||
selectedCollection && selectedCollection.onNewMongoShellClick();
|
||||
}
|
||||
},
|
||||
label:
|
||||
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
|
||||
? t(Keys.contextMenu.openMongoShell)
|
||||
: t(Keys.contextMenu.newShell),
|
||||
label: userContext.features.enableCloudShell ? t(Keys.contextMenu.openMongoShell) : t(Keys.contextMenu.newShell),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
|
||||
userContext.apiType === "Cassandra"
|
||||
) {
|
||||
if (userContext.features.enableCloudShell && userContext.apiType === "Cassandra") {
|
||||
items.push({
|
||||
iconSrc: HostedTerminalIcon,
|
||||
onClick: () => {
|
||||
|
||||
@@ -109,25 +109,15 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"computedProperties": [Function],
|
||||
"conflictResolutionPolicy": [Function],
|
||||
"container": Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
@@ -230,25 +220,15 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"computedProperties": [Function],
|
||||
"conflictResolutionPolicy": [Function],
|
||||
"container": Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
@@ -455,25 +435,15 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"computedProperties": [Function],
|
||||
"conflictResolutionPolicy": [Function],
|
||||
"container": Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
@@ -526,25 +496,15 @@ exports[`SettingsComponent renders 1`] = `
|
||||
}
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
@@ -694,25 +654,15 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"computedProperties": [Function],
|
||||
"conflictResolutionPolicy": [Function],
|
||||
"container": Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
@@ -765,25 +715,15 @@ exports[`SettingsComponent renders 1`] = `
|
||||
}
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Capability, DatabaseAccount } from "../Contracts/DataModels";
|
||||
import { updateUserContext, userContext } from "../UserContext";
|
||||
import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import Explorer from "./Explorer";
|
||||
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
|
||||
|
||||
const mockUpdate = update as jest.MockedFunction<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", () => {
|
||||
let explorer: Explorer;
|
||||
|
||||
@@ -69,6 +55,7 @@ describe("Explorer.openEnableSynapseLinkDialog", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue(undefined);
|
||||
useCommandBar.getState().setIsSynapseLinkUpdating(false);
|
||||
explorer = new Explorer();
|
||||
});
|
||||
|
||||
@@ -102,6 +89,19 @@ describe("Explorer.openEnableSynapseLinkDialog", () => {
|
||||
|
||||
expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(true);
|
||||
});
|
||||
|
||||
it("should track Synapse Link update progress in the command bar store", async () => {
|
||||
mockUpdate.mockImplementation(async () => {
|
||||
expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(true);
|
||||
return {} as Awaited<ReturnType<typeof update>>;
|
||||
});
|
||||
explorer.openEnableSynapseLinkDialog();
|
||||
|
||||
const dialogProps = mockOpenDialog.mock.calls[0][0];
|
||||
await dialogProps.onPrimaryButtonClick();
|
||||
|
||||
expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with targetAccountOverride", () => {
|
||||
|
||||
+5
-220
@@ -1,9 +1,6 @@
|
||||
import * as msal from "@azure/msal-browser";
|
||||
import { Link } from "@fluentui/react/lib/Link";
|
||||
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { stringifyError } from "Common/stringifyError";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
||||
import {
|
||||
@@ -13,32 +10,27 @@ import {
|
||||
scheduleRefreshFabricToken,
|
||||
} from "Platform/Fabric/FabricUtil";
|
||||
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
|
||||
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
|
||||
import { featureRegistered } from "Utils/FeatureRegistrationUtils";
|
||||
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import * as ko from "knockout";
|
||||
import React from "react";
|
||||
import _ from "underscore";
|
||||
import shallow from "zustand/shallow";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import { Areas, ConnectionStatusType, HttpStatusCodes, Notebook } from "../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../Common/ErrorHandlingUtils";
|
||||
import * as Logger from "../Common/Logger";
|
||||
import { QueriesClient } from "../Common/QueriesClient";
|
||||
import { readCollection } from "../Common/dataAccess/readCollection";
|
||||
import { readDatabases } from "../Common/dataAccess/readDatabases";
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { UploadDetailsRecord } from "../Contracts/ViewModels";
|
||||
import MetricScenario from "../Metrics/MetricEvents";
|
||||
import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig";
|
||||
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
|
||||
import { PhoenixClient } from "../Phoenix/PhoenixClient";
|
||||
import * as ExplorerSettings from "../Shared/ExplorerSettings";
|
||||
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
|
||||
import { Action } from "../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
|
||||
import { updateUserContext, userContext } from "../UserContext";
|
||||
import { getCollectionName, getUploadName } from "../Utils/APITypeUtils";
|
||||
@@ -49,8 +41,6 @@ import { ReactTabKind, useTabs } from "../hooks/useTabs";
|
||||
import "./ComponentRegisterer";
|
||||
import { DialogProps, useDialog } from "./Controls/Dialog";
|
||||
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import type NotebookManager from "./Notebook/NotebookManager";
|
||||
import { useNotebook } from "./Notebook/useNotebook";
|
||||
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
|
||||
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
|
||||
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
|
||||
@@ -78,13 +68,7 @@ export default class Explorer {
|
||||
// Tabs
|
||||
public isTabsContentExpanded: ko.Observable<boolean>;
|
||||
|
||||
// Notebooks
|
||||
public notebookManager?: NotebookManager;
|
||||
|
||||
private _isInitializingNotebooks: boolean;
|
||||
|
||||
private static readonly MaxNbDatabasesToAutoExpand = 5;
|
||||
public phoenixClient: PhoenixClient;
|
||||
|
||||
/**
|
||||
* Resolves when the initial refreshAllDatabases (including collection loading) completes.
|
||||
@@ -95,13 +79,6 @@ export default class Explorer {
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
|
||||
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||
});
|
||||
this._isInitializingNotebooks = false;
|
||||
|
||||
this.phoenixClient = new PhoenixClient(userContext?.databaseAccount?.id);
|
||||
useNotebook.subscribe(
|
||||
() => this.refreshCommandBarButtons(),
|
||||
(state) => state.isNotebooksEnabledForAccount,
|
||||
);
|
||||
|
||||
this.queriesClient = new QueriesClient(this);
|
||||
|
||||
@@ -156,31 +133,8 @@ export default class Explorer {
|
||||
startKey,
|
||||
);
|
||||
|
||||
useNotebook.subscribe(
|
||||
async () => this.initiateAndRefreshNotebookList(),
|
||||
(state) => [state.isNotebookEnabled, state.isRefreshed],
|
||||
shallow,
|
||||
);
|
||||
|
||||
this.resourceTree = new ResourceTreeAdapter(this);
|
||||
|
||||
// Override notebook server parameters from URL parameters
|
||||
if (
|
||||
userContext.features.notebookServerUrl &&
|
||||
validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) &&
|
||||
userContext.features.notebookServerToken
|
||||
) {
|
||||
useNotebook.getState().setNotebookServerInfo({
|
||||
notebookServerEndpoint: userContext.features.notebookServerUrl,
|
||||
authToken: userContext.features.notebookServerToken,
|
||||
forwardingId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (userContext.features.notebookBasePath) {
|
||||
useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath);
|
||||
}
|
||||
|
||||
if (isFabricMirrored()) {
|
||||
useTabs.getState().closeReactTab(ReactTabKind.Home);
|
||||
}
|
||||
@@ -188,23 +142,6 @@ export default class Explorer {
|
||||
this.refreshExplorer();
|
||||
}
|
||||
|
||||
public async initiateAndRefreshNotebookList(): Promise<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 {
|
||||
const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId;
|
||||
const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup;
|
||||
@@ -227,7 +164,7 @@ export default class Explorer {
|
||||
const clearInProgressMessage = logConsoleProgress(
|
||||
"Enabling Azure Synapse Link for this account. This may take a few minutes before you can enable analytical store for this account.",
|
||||
);
|
||||
useNotebook.getState().setIsSynapseLinkUpdating(true);
|
||||
useCommandBar.getState().setIsSynapseLinkUpdating(true);
|
||||
useDialog.getState().closeDialog();
|
||||
|
||||
try {
|
||||
@@ -248,7 +185,7 @@ export default class Explorer {
|
||||
logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`);
|
||||
TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime);
|
||||
} finally {
|
||||
useNotebook.getState().setIsSynapseLinkUpdating(false);
|
||||
useCommandBar.getState().setIsSynapseLinkUpdating(false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -431,7 +368,6 @@ export default class Explorer {
|
||||
await (userContext.authType === AuthType.ResourceToken
|
||||
? this.refreshDatabaseForResourceToken()
|
||||
: this.refreshAllDatabases());
|
||||
await this.refreshNotebookList();
|
||||
}
|
||||
|
||||
logConsoleInfo("Successfully refreshed databases");
|
||||
@@ -442,108 +378,6 @@ export default class Explorer {
|
||||
window.open(Constants.Urls.feedbackEmail, "_blank");
|
||||
};
|
||||
|
||||
public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise<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(
|
||||
updatedDatabaseList: DataModels.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 {
|
||||
this.connectToNotebookTerminal(kind);
|
||||
}
|
||||
@@ -742,24 +571,6 @@ export default class Explorer {
|
||||
.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> {
|
||||
// Start DatabaseLoad scenario before fetching databases
|
||||
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
|
||||
@@ -768,8 +579,7 @@ export default class Explorer {
|
||||
|
||||
// Run independent initialization tasks in parallel:
|
||||
// - Database loading (ARM/SDK calls for databases + collections)
|
||||
// - Notebook enabled check (Phoenix + Portal backend — no dependency on databases)
|
||||
// - Feature registration check (ARM call — no dependency on databases or notebooks)
|
||||
// - Feature registration check (ARM call — no dependency on databases)
|
||||
const databasesTask =
|
||||
userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo"
|
||||
? (async () => {
|
||||
@@ -786,37 +596,12 @@ export default class Explorer {
|
||||
})()
|
||||
: Promise.resolve();
|
||||
|
||||
const notebooksTask = !isFabricNative()
|
||||
? useNotebook.getState().refreshNotebooksEnabledStateForAccount()
|
||||
: Promise.resolve();
|
||||
|
||||
const featureRegistrationTask =
|
||||
userContext.authType === AuthType.AAD && userContext.apiType === "SQL" && !isFabricNative()
|
||||
? featureRegistered(userContext.subscriptionId, "ThroughputBucketing")
|
||||
: Promise.resolve(false);
|
||||
|
||||
const [, , throughputBucketsEnabled] = await Promise.all([databasesTask, notebooksTask, featureRegistrationTask]);
|
||||
|
||||
// Notebook initialization depends on refreshNotebooksEnabledStateForAccount completing above
|
||||
// TODO: remove reference to isNotebookEnabled and isNotebooksEnabledForAccount
|
||||
const isNotebookEnabled =
|
||||
configContext.platform !== Platform.Fabric &&
|
||||
(userContext.features.notebooksDownBanner ||
|
||||
useNotebook.getState().isPhoenixNotebooks ||
|
||||
useNotebook.getState().isPhoenixFeatures);
|
||||
useNotebook.getState().setIsNotebookEnabled(isNotebookEnabled);
|
||||
useNotebook
|
||||
.getState()
|
||||
.setIsShellEnabled(useNotebook.getState().isPhoenixFeatures && isPublicInternetAccessAllowed());
|
||||
|
||||
TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, {
|
||||
isNotebookEnabled,
|
||||
dataExplorerArea: Constants.Areas.Notebook,
|
||||
});
|
||||
|
||||
if (useNotebook.getState().isPhoenixNotebooks) {
|
||||
await this.initNotebooks(userContext.databaseAccount);
|
||||
}
|
||||
const [, throughputBucketsEnabled] = await Promise.all([databasesTask, featureRegistrationTask]);
|
||||
|
||||
if (throughputBucketsEnabled) {
|
||||
updateUserContext({ throughputBucketsEnabled });
|
||||
|
||||
@@ -5,14 +5,12 @@
|
||||
*/
|
||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||
import { makeStyles, useFluent } from "@fluentui/react-components";
|
||||
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
||||
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { userContext } from "UserContext";
|
||||
import * as React from "react";
|
||||
import create, { UseStore } from "zustand";
|
||||
import { ConnectionStatusType } from "../../../Common/Constants";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import { useSelectedNode } from "../../useSelectedNode";
|
||||
@@ -28,6 +26,8 @@ export interface CommandBarStore {
|
||||
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
|
||||
isHidden: boolean;
|
||||
setIsHidden: (isHidden: boolean) => void;
|
||||
isSynapseLinkUpdating: boolean;
|
||||
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void;
|
||||
}
|
||||
|
||||
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
|
||||
@@ -35,6 +35,8 @@ export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
|
||||
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
|
||||
isHidden: false,
|
||||
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
|
||||
isSynapseLinkUpdating: false,
|
||||
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }),
|
||||
}));
|
||||
|
||||
const useStyles = makeStyles({
|
||||
@@ -69,26 +71,25 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
|
||||
const selectedNodeState = useSelectedNode();
|
||||
const buttons = useCommandBar((state) => state.contextButtons);
|
||||
const isHidden = useCommandBar((state) => state.isHidden);
|
||||
const isSynapseLinkUpdating = useCommandBar((state) => state.isSynapseLinkUpdating);
|
||||
// targetDocument is used by referenced components
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { targetDocument } = useFluent();
|
||||
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
|
||||
const styles = useStyles();
|
||||
|
||||
const { connectionInfo, isPhoenixNotebooks, isPhoenixFeatures } = useNotebook((state) => ({
|
||||
connectionInfo: state.connectionInfo,
|
||||
isPhoenixNotebooks: state.isPhoenixNotebooks,
|
||||
isPhoenixFeatures: state.isPhoenixFeatures,
|
||||
}));
|
||||
|
||||
// Subscribe to the store changes that affect button creation
|
||||
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
|
||||
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
|
||||
|
||||
// Memoize the expensive button creation
|
||||
const staticButtons = React.useMemo(() => {
|
||||
return CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState);
|
||||
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated]);
|
||||
return CommandBarComponentButtonFactory.createStaticCommandBarButtons(
|
||||
container,
|
||||
selectedNodeState,
|
||||
isSynapseLinkUpdating,
|
||||
);
|
||||
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated, isSynapseLinkUpdating]);
|
||||
|
||||
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
|
||||
const buttons =
|
||||
@@ -134,11 +135,6 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
|
||||
const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, "var(--colorNeutralBackground1)");
|
||||
uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
|
||||
|
||||
// Add connection status if needed (using the hook values we got at the top level)
|
||||
if ((isPhoenixNotebooks || isPhoenixFeatures) && connectionInfo?.status !== ConnectionStatusType.Connect) {
|
||||
uiFabricControlButtons.unshift(CommandBarUtil.createConnectionStatus(container, "connectionStatus"));
|
||||
}
|
||||
|
||||
const rootStyle = {
|
||||
root: {
|
||||
backgroundColor: "var(--colorNeutralBackground1)",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as ko from "knockout";
|
||||
import { extractFeatures } from "Platform/Hosted/extractFeatures";
|
||||
import { AuthType } from "../../../AuthType";
|
||||
import { DatabaseAccount } from "../../../Contracts/DataModels";
|
||||
import { CollectionBase } from "../../../Contracts/ViewModels";
|
||||
import { updateUserContext } from "../../../UserContext";
|
||||
import { CollectionBase, TerminalKind } from "../../../Contracts/ViewModels";
|
||||
import { ApiType, updateUserContext, userContext } from "../../../UserContext";
|
||||
import Explorer from "../../Explorer";
|
||||
import { useNotebook } from "../../Notebook/useNotebook";
|
||||
import { useDatabases } from "../../useDatabases";
|
||||
import { useSelectedNode } from "../../useSelectedNode";
|
||||
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
|
||||
@@ -37,6 +37,18 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
||||
expect(enableAzureSynapseLinkBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("Button should be disabled while Synapse Link is updating", () => {
|
||||
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(
|
||||
mockExplorer,
|
||||
selectedNodeState,
|
||||
true,
|
||||
);
|
||||
const enableAzureSynapseLinkBtn = buttons.find(
|
||||
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
|
||||
);
|
||||
expect(enableAzureSynapseLinkBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// TODO: Now that Tables API supports dataplane RBAC, calling createStaticCommandBarButtons will enable the
|
||||
// Entra ID Login button, which causes this test to fail due to "Invalid hook call.". This seems to be
|
||||
// unsupported in jest and needs to be tested with react-hooks-testing-library.
|
||||
@@ -99,11 +111,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useNotebook.getState().setIsNotebookEnabled(false);
|
||||
useNotebook.getState().setIsNotebooksEnabledForAccount(false);
|
||||
});
|
||||
|
||||
it("Cassandra Api not available - button should be hidden", () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
@@ -127,7 +134,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
||||
expect(openCassandraShellBtn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
|
||||
it("Cloud Shell is unavailable - button should be hidden", () => {
|
||||
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
|
||||
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
|
||||
expect(openCassandraShellBtn).toBeUndefined();
|
||||
@@ -159,6 +166,60 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Open Cosmos DB Shell button", () => {
|
||||
let originalUserContext: typeof userContext;
|
||||
|
||||
beforeEach(() => {
|
||||
originalUserContext = { ...userContext };
|
||||
mockExplorer = { openNotebookTerminal: jest.fn() } as unknown as Explorer;
|
||||
updateUserContext({
|
||||
authType: AuthType.AAD,
|
||||
databaseAccount: { kind: "DocumentDB", properties: { capabilities: [] } } as DatabaseAccount,
|
||||
features: extractFeatures(new URLSearchParams()),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => updateUserContext(originalUserContext));
|
||||
|
||||
const getShellButton = () =>
|
||||
CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, useSelectedNode.getState()).find(
|
||||
(button) => button.commandButtonLabel === "Open Cosmos DB Shell",
|
||||
);
|
||||
|
||||
it("shows and opens the shell with default features", () => {
|
||||
const button = getShellButton();
|
||||
expect(button).toBeDefined();
|
||||
expect(button.disabled).toBe(false);
|
||||
button.onCommandClick(new KeyboardEvent("keydown", { key: "Enter" }));
|
||||
expect(mockExplorer.openNotebookTerminal).toHaveBeenCalledWith(TerminalKind.CosmosDB);
|
||||
});
|
||||
|
||||
it("hides the shell when explicitly disabled by the feature flag", () => {
|
||||
updateUserContext({
|
||||
features: extractFeatures(new URLSearchParams({ "feature.enableCosmosDBShell": "false" })),
|
||||
});
|
||||
expect(getShellButton()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the shell when Cloud Shell is unavailable", () => {
|
||||
updateUserContext({ features: { ...userContext.features, enableCloudShell: false } });
|
||||
expect(getShellButton()).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each<ApiType>(["Mongo", "Cassandra", "Gremlin", "Tables", "Postgres", "VCoreMongo"])(
|
||||
"hides the shell for the %s API",
|
||||
(apiType) => {
|
||||
updateUserContext({ apiType });
|
||||
expect(getShellButton()).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("hides the shell for resource token authentication", () => {
|
||||
updateUserContext({ authType: AuthType.ResourceToken });
|
||||
expect(getShellButton()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resource token", () => {
|
||||
const mockCollection = { id: ko.observable("test") } as CollectionBase;
|
||||
useSelectedNode.getState().setSelectedNode(mockCollection);
|
||||
|
||||
@@ -30,7 +30,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import { useDialog } from "../../Controls/Dialog";
|
||||
import Explorer from "../../Explorer";
|
||||
import { useNotebook } from "../../Notebook/useNotebook";
|
||||
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
|
||||
import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane";
|
||||
import { SettingsPane, useDataPlaneRbac } from "../../Panes/SettingsPane/SettingsPane";
|
||||
@@ -43,6 +42,7 @@ let counter = 0;
|
||||
export function createStaticCommandBarButtons(
|
||||
container: Explorer,
|
||||
selectedNodeState: SelectedNodeState,
|
||||
isSynapseLinkUpdating = false,
|
||||
): CommandButtonComponentProps[] {
|
||||
if (userContext.authType === AuthType.ResourceToken) {
|
||||
return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
|
||||
@@ -62,7 +62,7 @@ export function createStaticCommandBarButtons(
|
||||
userContext.apiType !== "Tables" &&
|
||||
userContext.apiType !== "Cassandra"
|
||||
) {
|
||||
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
|
||||
const addSynapseLink = createOpenSynapseLinkDialogButton(container, isSynapseLinkUpdating);
|
||||
if (addSynapseLink) {
|
||||
addDivider();
|
||||
buttons.push(addSynapseLink);
|
||||
@@ -136,14 +136,13 @@ export function createContextCommandBarButtons(
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
|
||||
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
|
||||
const label =
|
||||
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
|
||||
const label = userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
|
||||
const newMongoShellBtn: CommandButtonComponentProps = {
|
||||
iconSrc: HostedTerminalIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
|
||||
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
|
||||
if (userContext.features.enableCloudShell) {
|
||||
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
|
||||
} else {
|
||||
selectedCollection && selectedCollection.onNewMongoShellClick();
|
||||
@@ -157,7 +156,7 @@ export function createContextCommandBarButtons(
|
||||
}
|
||||
|
||||
if (
|
||||
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
|
||||
userContext.features.enableCloudShell &&
|
||||
!selectedNodeState.isDatabaseNodeOrNoneSelected() &&
|
||||
userContext.apiType === "Cassandra"
|
||||
) {
|
||||
@@ -252,7 +251,10 @@ function areScriptsSupported(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
|
||||
function createOpenSynapseLinkDialogButton(
|
||||
container: Explorer,
|
||||
isSynapseLinkUpdating: boolean,
|
||||
): CommandButtonComponentProps {
|
||||
if (configContext.platform === Platform.Emulator) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -273,7 +275,7 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
|
||||
onCommandClick: () => container.openEnableSynapseLinkDialog(),
|
||||
commandButtonLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: useNotebook.getState().isSynapseLinkUpdating,
|
||||
disabled: isSynapseLinkUpdating,
|
||||
ariaLabel: label,
|
||||
};
|
||||
}
|
||||
@@ -514,14 +516,9 @@ function createOpenTerminalButtonByKind(
|
||||
}
|
||||
};
|
||||
const label = `Open ${terminalFriendlyName()} Shell`;
|
||||
const tooltip =
|
||||
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks.";
|
||||
const tooltip = "Cloud Shell is not available for this account.";
|
||||
const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled();
|
||||
const disableButton =
|
||||
(!useNotebook.getState().isNotebooksEnabledForAccount &&
|
||||
!useNotebook.getState().isNotebookEnabled &&
|
||||
!userContext.features.enableCloudShell) ||
|
||||
isNativeAuthDisabled;
|
||||
const disableButton = !userContext.features.enableCloudShell || isNativeAuthDisabled;
|
||||
return {
|
||||
iconSrc: HostedTerminalIcon,
|
||||
iconAlt: label,
|
||||
@@ -533,7 +530,7 @@ function createOpenTerminalButtonByKind(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) {
|
||||
if (userContext.features.enableCloudShell) {
|
||||
container.openNotebookTerminal(terminalKind);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,9 +17,6 @@ import { configContext, Platform } from "../../../ConfigContext";
|
||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import { ConnectionStatus } from "./ConnectionStatusComponent";
|
||||
import { MemoryTracker } from "./MemoryTrackerComponent";
|
||||
|
||||
/**
|
||||
* Convert our NavbarButtonConfig to UI Fabric buttons
|
||||
@@ -258,20 +255,6 @@ export const createDivider = (key: string): ICommandBarItemProps => {
|
||||
};
|
||||
};
|
||||
|
||||
export const createMemoryTracker = (key: string): ICommandBarItemProps => {
|
||||
return {
|
||||
key,
|
||||
onRender: () => <MemoryTracker />,
|
||||
};
|
||||
};
|
||||
|
||||
export const createConnectionStatus = (container: Explorer, key: string): ICommandBarItemProps => {
|
||||
return {
|
||||
key,
|
||||
onRender: () => <ConnectionStatus container={container} />,
|
||||
};
|
||||
};
|
||||
|
||||
export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): 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
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"databasesRefreshed": Promise {},
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
|
||||
@@ -43,7 +43,6 @@ import { getCollectionName } from "../../Utils/APITypeUtils";
|
||||
import { useDialog } from "../Controls/Dialog";
|
||||
import Explorer from "../Explorer";
|
||||
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
|
||||
import { useNotebook } from "../Notebook/useNotebook";
|
||||
import { useDatabases } from "../useDatabases";
|
||||
import { useSelectedNode } from "../useSelectedNode";
|
||||
|
||||
@@ -188,12 +187,6 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
|
||||
React.useEffect(() => {
|
||||
subscriptions.push(
|
||||
{
|
||||
dispose: useNotebook.subscribe(
|
||||
() => setState({}),
|
||||
(state) => state.isNotebookEnabled,
|
||||
),
|
||||
},
|
||||
{ dispose: useSelectedNode.subscribe(() => setState({})) },
|
||||
{
|
||||
dispose: useCarousel.subscribe(
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("CloudShellClient", () => {
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "GET",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
});
|
||||
expect(result).toEqual(mockSettings);
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe("CloudShellClient", () => {
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "GET",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("CloudShellClient", () => {
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
body: {
|
||||
properties: {
|
||||
preferredOsType: OsType.Linux,
|
||||
@@ -144,7 +144,7 @@ describe("CloudShellClient", () => {
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
body: {
|
||||
properties: {
|
||||
preferredOsType: OsType.Linux,
|
||||
@@ -247,7 +247,7 @@ describe("CloudShellClient", () => {
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "providers/Microsoft.Portal/consoles/default",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
customHeaders: {
|
||||
"x-ms-console-preferred-location": "eastus",
|
||||
},
|
||||
@@ -271,7 +271,7 @@ describe("CloudShellClient", () => {
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "providers/Microsoft.Portal/consoles/default",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
customHeaders: {
|
||||
"x-ms-console-preferred-location": "eastus",
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getUserSettings = async (): Promise<CloudShellSettings> => {
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
|
||||
method: "GET",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export const putEphemeralUserSettings = async (
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
body: ephemeralSettings,
|
||||
timeoutMs: CLOUDSHELL_ARM_TIMEOUT_MS,
|
||||
});
|
||||
@@ -92,7 +92,7 @@ export const provisionConsole = async (consoleLocation: string): Promise<Provisi
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `providers/Microsoft.Portal/consoles/default`,
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
apiVersion: "2025-09-01-preview",
|
||||
customHeaders: {
|
||||
"x-ms-console-preferred-location": consoleLocation,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,9 @@ export const EXIT_COMMAND_MONGO = ` printf "\\033[1;31mSession ended. Please clo
|
||||
*/
|
||||
export const DISABLE_TELEMETRY_COMMAND = `mongosh --nodb --quiet --eval 'disableTelemetry()'`;
|
||||
|
||||
const MONGOSH_PACKAGE_VERSION = "2.5.6";
|
||||
const MONGOSH_36_PACKAGE_VERSION = "1.10.6";
|
||||
|
||||
/**
|
||||
* Abstract class that defines the interface for shell-specific handlers
|
||||
* in the CloudShell terminal implementation. Each supported shell type
|
||||
@@ -96,14 +99,14 @@ export abstract class AbstractShellHandler {
|
||||
* Each command runs conditionally only if mongosh
|
||||
* is not already present in the environment.
|
||||
*/
|
||||
protected mongoShellSetupCommands(): string[] {
|
||||
const PACKAGE_VERSION: string = "2.5.6";
|
||||
protected mongoShellSetupCommands(serverVersion?: string): string[] {
|
||||
const packageVersion = serverVersion === "3.6" ? MONGOSH_36_PACKAGE_VERSION : MONGOSH_PACKAGE_VERSION;
|
||||
return [
|
||||
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
|
||||
`if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh/bin && mv mongosh-${PACKAGE_VERSION}-linux-x64/bin/mongosh ~/mongosh/bin/ && chmod +x ~/mongosh/bin/mongosh; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then rm -rf mongosh-${PACKAGE_VERSION}-linux-x64 mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-${packageVersion}-linux-x64.tgz; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-${packageVersion}-linux-x64.tgz; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh/bin && mv mongosh-${packageVersion}-linux-x64/bin/mongosh ~/mongosh/bin/ && chmod +x ~/mongosh/bin/mongosh; fi`,
|
||||
`if ! command -v mongosh &> /dev/null; then rm -rf mongosh-${packageVersion}-linux-x64 mongosh-${packageVersion}-linux-x64.tgz; fi`,
|
||||
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
|
||||
"if ! command -v mongosh &> /dev/null; then source ~/.bashrc; fi",
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Localization from "Localization/t";
|
||||
import { CosmosDBShellHandler } from "./CosmosDBShellHandler";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -14,8 +15,9 @@ jest.mock("../../../../UserContext", () => ({
|
||||
describe("CosmosDBShellHandler", () => {
|
||||
const mockKey = "testKey";
|
||||
const endpoint = "https://test-account.documents.azure.com:443/";
|
||||
const tokenConnectionCommand = `export COSMOSDB_SHELL_TOKEN='aadToken123'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
|
||||
const keyConnectionCommand = `export COSMOSDB_SHELL_ACCOUNT_KEY='${mockKey}'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
|
||||
const connectionProgress = "printf '%s\\n' 'Connecting to your Cosmos DB account...'";
|
||||
const tokenConnectionCommand = `${connectionProgress}; export COSMOSDB_SHELL_TOKEN='aadToken123'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
|
||||
const keyConnectionCommand = `${connectionProgress}; export COSMOSDB_SHELL_ACCOUNT_KEY='${mockKey}'; cosmosdbshell --connect '${endpoint}' --connect-mode gateway --verbose`;
|
||||
let cosmosDBShellHandler: CosmosDBShellHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -54,6 +56,32 @@ describe("CosmosDBShellHandler", () => {
|
||||
expect(commands.some((c) => c === "export DOTNET_ROOT=$HOME/.dotnet")).toBe(true);
|
||||
});
|
||||
|
||||
it("should describe each setup step only in the corresponding command branch", () => {
|
||||
const commands = cosmosDBShellHandler.getSetUpCommands();
|
||||
const sdkCommand = commands.find((command) => command.includes("dotnet-install.sh"));
|
||||
const shellCommand = commands.find((command) => command.includes("dotnet tool install"));
|
||||
|
||||
expect(sdkCommand).toContain(
|
||||
"then printf '%s\\n' 'Downloading and installing .NET SDK 10. First-time setup may take a few minutes.'; curl",
|
||||
);
|
||||
expect(shellCommand).toContain("then printf '%s\\n' 'Installing Cosmos DB Shell...'; dotnet tool install");
|
||||
expect(shellCommand).toContain(
|
||||
"else printf '%s\\n' 'Checking for Cosmos DB Shell updates...'; dotnet tool update",
|
||||
);
|
||||
expect(commands).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should quote localized progress messages safely for Bash", () => {
|
||||
const translation = jest.spyOn(Localization, "t").mockReturnValue("Account's $HOME `command` %s");
|
||||
try {
|
||||
expect(cosmosDBShellHandler.getConnectionCommand()).toContain(
|
||||
"printf '%s\\n' 'Account'\\''s $HOME `command` %s'; export",
|
||||
);
|
||||
} finally {
|
||||
translation.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should not export any credential env var in setup commands for a key credential", () => {
|
||||
const commands = cosmosDBShellHandler.getSetUpCommands();
|
||||
|
||||
@@ -107,6 +135,7 @@ describe("CosmosDBShellHandler", () => {
|
||||
const connectionCommand = handler.getConnectionCommand();
|
||||
|
||||
expect(connectionCommand).not.toContain("cosmosdbshell --connect");
|
||||
expect(connectionCommand).not.toContain("Connecting to your Cosmos DB account");
|
||||
expect(connectionCommand).toContain("Unable to acquire a Cosmos DB credential");
|
||||
expect(connectionCommand).toContain("Login for Entra ID");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceKey, t } from "Localization/t";
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
|
||||
@@ -65,14 +66,23 @@ export class CosmosDBShellHandler extends AbstractShellHandler {
|
||||
return [
|
||||
"export DOTNET_ROOT=$HOME/.dotnet",
|
||||
"export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH",
|
||||
"if ! command -v cosmosdbshell &> /dev/null; then echo '⚠️ cosmosdbshell not found. Installing .NET SDK 10 and CosmosDBShell...'; fi",
|
||||
"if ! command -v cosmosdbshell &> /dev/null && ! dotnet --list-sdks 2>/dev/null | grep -q '^10\\.'; then curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir $HOME/.dotnet; fi",
|
||||
"if ! command -v cosmosdbshell &> /dev/null; then dotnet tool install --global CosmosDBShell --prerelease; else dotnet tool update --global CosmosDBShell --prerelease; fi",
|
||||
`if ! command -v cosmosdbshell &> /dev/null && ! dotnet --list-sdks 2>/dev/null | grep -q '^10\\.'; then ${this._getProgressCommand(
|
||||
"cosmosDBShell.installingSdk",
|
||||
)}; curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir $HOME/.dotnet; fi`,
|
||||
`if ! command -v cosmosdbshell &> /dev/null; then ${this._getProgressCommand(
|
||||
"cosmosDBShell.installingShell",
|
||||
)}; dotnet tool install --global CosmosDBShell --prerelease; else ${this._getProgressCommand(
|
||||
"cosmosDBShell.updatingShell",
|
||||
)}; dotnet tool update --global CosmosDBShell --prerelease; fi`,
|
||||
"grep -qxF 'export DOTNET_ROOT=$HOME/.dotnet' ~/.bashrc || echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc",
|
||||
"grep -qxF 'export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH' ~/.bashrc || echo 'export PATH=$HOME/.dotnet:$HOME/.dotnet/tools:$PATH' >> ~/.bashrc",
|
||||
];
|
||||
}
|
||||
|
||||
private _getProgressCommand(key: ResourceKey): string {
|
||||
return `printf '%s\\n' '${t(key).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
private _getKeyConnectionCommand(key: string): string {
|
||||
// Export the key immediately before invoking the tool, on the same command line, so it
|
||||
// never appears as a --connect flag value or lands in a separate setup step. The tool
|
||||
@@ -118,9 +128,11 @@ export class CosmosDBShellHandler extends AbstractShellHandler {
|
||||
endpoint: this._endpoint,
|
||||
});
|
||||
|
||||
return this.credential.kind === "key"
|
||||
? this._getKeyConnectionCommand(this.credential.value)
|
||||
: this._getTokenConnectionCommand(this.credential.value);
|
||||
const connectionCommand =
|
||||
this.credential.kind === "key"
|
||||
? this._getKeyConnectionCommand(this.credential.value)
|
||||
: this._getTokenConnectionCommand(this.credential.value);
|
||||
return `${this._getProgressCommand("cosmosDBShell.connecting")}; ${connectionCommand}`;
|
||||
}
|
||||
|
||||
public getTerminalSuppressedData(): string[] {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { MongoShellHandler } from "./MongoShellHandler";
|
||||
// Define interfaces for type safety
|
||||
interface DatabaseAccountProperties {
|
||||
mongoEndpoint?: string;
|
||||
apiProperties?: {
|
||||
serverVersion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DatabaseAccount {
|
||||
@@ -80,6 +83,17 @@ describe("MongoShellHandler", () => {
|
||||
expect(commands.length).toBe(7);
|
||||
expect(commands[1]).toContain("mongosh-2.5.6-linux-x64.tgz");
|
||||
});
|
||||
|
||||
it("should download a MongoDB 3.6-compatible package for 3.6 accounts", () => {
|
||||
const properties = (userContext as UserContextType).databaseAccount.properties;
|
||||
const originalApiProperties = properties.apiProperties;
|
||||
properties.apiProperties = { serverVersion: "3.6" };
|
||||
|
||||
const commands = mongoShellHandler.getSetUpCommands();
|
||||
|
||||
expect(commands[1]).toContain("mongosh-1.10.6-linux-x64.tgz");
|
||||
properties.apiProperties = originalApiProperties;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnectionCommand", () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export class MongoShellHandler extends AbstractShellHandler {
|
||||
}
|
||||
|
||||
public getSetUpCommands(): string[] {
|
||||
return this.mongoShellSetupCommands();
|
||||
return this.mongoShellSetupCommands(userContext.databaseAccount?.properties.apiProperties?.serverVersion);
|
||||
}
|
||||
|
||||
public getConnectionCommand(): string {
|
||||
|
||||
@@ -35,15 +35,6 @@ import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { updateUserContext } from "../../../UserContext";
|
||||
import Explorer from "../../Explorer";
|
||||
|
||||
jest.mock("rx-jupyter", () => ({
|
||||
sessions: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
contents: {
|
||||
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("Common/dataAccess/queryDocuments", () => ({
|
||||
queryDocuments: jest.fn(() => ({
|
||||
// Omit headers, because we can't mock a private field and we don't need to test it
|
||||
|
||||
@@ -19,15 +19,6 @@ import { act } from "react-dom/test-utils";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import Explorer from "../../Explorer";
|
||||
|
||||
jest.mock("rx-jupyter", () => ({
|
||||
sessions: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
contents: {
|
||||
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.requireActual("Explorer/Controls/Editor/EditorReact");
|
||||
|
||||
const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__";
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
TriggerDefinition,
|
||||
UserDefinedFunctionDefinition,
|
||||
} from "@azure/cosmos";
|
||||
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
||||
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||
import { isFabricMirrored } from "Platform/Fabric/FabricUtil";
|
||||
import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils";
|
||||
|
||||
@@ -6,15 +6,6 @@ import { updateUserContext, userContext } from "../../UserContext";
|
||||
import Explorer from "../Explorer";
|
||||
import Database from "./Database";
|
||||
|
||||
jest.mock("rx-jupyter", () => ({
|
||||
sessions: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
contents: {
|
||||
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
const createMockContainer = (): Explorer => {
|
||||
const mockContainer = new Explorer();
|
||||
return mockContainer;
|
||||
|
||||
@@ -18,12 +18,8 @@ import { userContext } from "UserContext";
|
||||
import { ReactTabKind, useTabs } from "hooks/useTabs";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import shallow from "zustand/shallow";
|
||||
import { useDatabaseLoadScenario } from "../../Metrics/useMetricPhases";
|
||||
import Explorer from "../Explorer";
|
||||
import { useNotebook } from "../Notebook/useNotebook";
|
||||
|
||||
export const MyNotebooksTitle = "My Notebooks";
|
||||
|
||||
interface ResourceTreeProps {
|
||||
explorer: Explorer;
|
||||
@@ -40,13 +36,6 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
|
||||
const [openItems, setOpenItems] = React.useState<TreeItemValue[]>([]);
|
||||
const treeStyles = useTreeStyles();
|
||||
|
||||
const { isNotebookEnabled } = useNotebook(
|
||||
(state) => ({
|
||||
isNotebookEnabled: state.isNotebookEnabled,
|
||||
}),
|
||||
shallow,
|
||||
);
|
||||
|
||||
// We intentionally avoid using a state selector here because we want to re-render the tree if the active tab changes.
|
||||
const { refreshActiveTab } = useTabs();
|
||||
|
||||
@@ -65,24 +54,8 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
|
||||
const databaseTreeNodes = useMemo(() => {
|
||||
return userContext.authType === AuthType.ResourceToken
|
||||
? createResourceTokenTreeNodes(resourceTokenCollection)
|
||||
: createDatabaseTreeNodes(
|
||||
explorer,
|
||||
isNotebookEnabled,
|
||||
databases,
|
||||
refreshActiveTab,
|
||||
searchText,
|
||||
sortOrder,
|
||||
pinnedDatabaseIds,
|
||||
);
|
||||
}, [
|
||||
resourceTokenCollection,
|
||||
databases,
|
||||
isNotebookEnabled,
|
||||
refreshActiveTab,
|
||||
searchText,
|
||||
sortOrder,
|
||||
pinnedDatabaseIds,
|
||||
]);
|
||||
: createDatabaseTreeNodes(explorer, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds);
|
||||
}, [resourceTokenCollection, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds]);
|
||||
|
||||
const headerNodes: TreeNode[] = isFabricMirrored()
|
||||
? []
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
|
||||
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
|
||||
import { getItemName } from "Utils/APITypeUtils";
|
||||
import * as ko from "knockout";
|
||||
import * as React from "react";
|
||||
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
|
||||
import DeleteIcon from "../../../images/delete.svg";
|
||||
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
|
||||
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
|
||||
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
|
||||
import FileIcon from "../../../images/notebook/file-cosmos.svg";
|
||||
import RefreshIcon from "../../../images/refresh-cosmos.svg";
|
||||
import CollectionIcon from "../../../images/tree-collection.svg";
|
||||
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
||||
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
|
||||
import { useTabs } from "../../hooks/useTabs";
|
||||
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
|
||||
import { useDialog } from "../Controls/Dialog";
|
||||
import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
|
||||
import Explorer from "../Explorer";
|
||||
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
|
||||
import { NotebookUtil } from "../Notebook/NotebookUtil";
|
||||
import { useNotebook } from "../Notebook/useNotebook";
|
||||
import TabsBase from "../Tabs/TabsBase";
|
||||
import { useDatabases } from "../useDatabases";
|
||||
import { useSelectedNode } from "../useSelectedNode";
|
||||
@@ -37,16 +23,10 @@ import Trigger from "./Trigger";
|
||||
import UserDefinedFunction from "./UserDefinedFunction";
|
||||
|
||||
export class ResourceTreeAdapter implements ReactAdapter {
|
||||
public static readonly MyNotebooksTitle = "My Notebooks";
|
||||
|
||||
private static readonly DataTitle = "DATA";
|
||||
private static readonly NotebooksTitle = "NOTEBOOKS";
|
||||
private static readonly PseudoDirPath = "PsuedoDir";
|
||||
|
||||
public parameters: ko.Observable<number>;
|
||||
|
||||
public myNotebooksContentRoot: NotebookContentItem;
|
||||
|
||||
public constructor(private container: Explorer) {
|
||||
this.parameters = ko.observable(Date.now());
|
||||
|
||||
@@ -55,56 +35,15 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
||||
() => this.triggerRender(),
|
||||
(state) => state.activeTab,
|
||||
);
|
||||
useNotebook.subscribe(
|
||||
() => this.triggerRender(),
|
||||
(state) => state.isNotebookEnabled,
|
||||
);
|
||||
|
||||
useDatabases.subscribe(() => this.triggerRender());
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
private traceMyNotebookTreeInfo() {
|
||||
const myNotebooksTree = this.myNotebooksContentRoot;
|
||||
if (myNotebooksTree.children) {
|
||||
// Count 1st generation children (tree is lazy-loaded)
|
||||
const nodeCounts = { files: 0, notebooks: 0, directories: 0 };
|
||||
myNotebooksTree.children.forEach((treeNode) => {
|
||||
switch ((treeNode as NotebookContentItem).type) {
|
||||
case NotebookContentItemType.File:
|
||||
nodeCounts.files++;
|
||||
break;
|
||||
case NotebookContentItemType.Directory:
|
||||
nodeCounts.directories++;
|
||||
break;
|
||||
case NotebookContentItemType.Notebook:
|
||||
nodeCounts.notebooks++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
TelemetryProcessor.trace(Action.RefreshResourceTreeMyNotebooks, ActionModifiers.Mark, { ...nodeCounts });
|
||||
}
|
||||
}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
const dataRootNode = this.buildDataTree();
|
||||
return <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 {
|
||||
const databaseTreeNodes: LegacyTreeNode[] = useDatabases
|
||||
.getState()
|
||||
@@ -403,93 +342,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
||||
return traverse(schema);
|
||||
}
|
||||
|
||||
private buildChildNodes(
|
||||
item: NotebookContentItem,
|
||||
onFileClick: (item: NotebookContentItem) => void,
|
||||
createDirectoryContextMenu: boolean,
|
||||
createFileContextMenu: boolean,
|
||||
): LegacyTreeNode[] {
|
||||
if (!item || !item.children) {
|
||||
return [];
|
||||
} else {
|
||||
return item.children.map((item) => {
|
||||
const result =
|
||||
item.type === NotebookContentItemType.Directory
|
||||
? this.buildNotebookDirectoryNode(item, onFileClick, createDirectoryContextMenu, createFileContextMenu)
|
||||
: this.buildNotebookFileNode(item, onFileClick, createFileContextMenu);
|
||||
result.timestamp = item.timestamp;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildNotebookFileNode(
|
||||
item: NotebookContentItem,
|
||||
onFileClick: (item: NotebookContentItem) => void,
|
||||
createFileContextMenu: boolean,
|
||||
): LegacyTreeNode {
|
||||
return {
|
||||
label: item.name,
|
||||
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
|
||||
className: "notebookHeader",
|
||||
onClick: () => onFileClick(item),
|
||||
isSelected: () => {
|
||||
const activeTab = useTabs.getState().activeTab;
|
||||
return (
|
||||
activeTab &&
|
||||
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
|
||||
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
|
||||
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
|
||||
*/
|
||||
(activeTab as any).notebookPath() === item.path
|
||||
);
|
||||
},
|
||||
contextMenu: createFileContextMenu && this.createFileContextMenu(),
|
||||
data: item,
|
||||
};
|
||||
}
|
||||
|
||||
private createFileContextMenu(): TreeNodeMenuItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
private createDirectoryContextMenu(): TreeNodeMenuItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
private buildNotebookDirectoryNode(
|
||||
item: NotebookContentItem,
|
||||
onFileClick: (item: NotebookContentItem) => void,
|
||||
createDirectoryContextMenu: boolean,
|
||||
createFileContextMenu: boolean,
|
||||
): LegacyTreeNode {
|
||||
return {
|
||||
label: item.name,
|
||||
iconSrc: undefined,
|
||||
className: "notebookHeader",
|
||||
isAlphaSorted: true,
|
||||
isLeavesParentsSeparate: true,
|
||||
onClick: undefined,
|
||||
isSelected: () => {
|
||||
const activeTab = useTabs.getState().activeTab;
|
||||
return (
|
||||
activeTab &&
|
||||
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
|
||||
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
|
||||
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
|
||||
*/
|
||||
(activeTab as any).notebookPath() === item.path
|
||||
);
|
||||
},
|
||||
contextMenu:
|
||||
createDirectoryContextMenu && item.path !== ResourceTreeAdapter.PseudoDirPath
|
||||
? this.createDirectoryContextMenu()
|
||||
: undefined,
|
||||
data: item,
|
||||
children: this.buildChildNodes(item, onFileClick, createDirectoryContextMenu, createFileContextMenu),
|
||||
};
|
||||
}
|
||||
|
||||
public triggerRender() {
|
||||
window.requestAnimationFrame(() => this.parameters(Date.now()));
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
|
||||
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
||||
import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel";
|
||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||
import StoredProcedure from "Explorer/Tree/StoredProcedure";
|
||||
@@ -363,7 +362,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
});
|
||||
|
||||
it("creates expected tree", () => {
|
||||
@@ -371,11 +370,10 @@ describe("createDatabaseTreeNodes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, Platform, boolean, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([
|
||||
it.each<[string, Platform, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([
|
||||
[
|
||||
"the SQL API, on Fabric read-only (mirrored)",
|
||||
Platform.Fabric,
|
||||
false,
|
||||
{ capabilities: [], enableMultipleWriteLocations: true },
|
||||
{
|
||||
fabricContext: {
|
||||
@@ -387,7 +385,6 @@ describe("createDatabaseTreeNodes", () => {
|
||||
[
|
||||
"the SQL API, on Fabric non read-only (native)",
|
||||
Platform.Fabric,
|
||||
false,
|
||||
{ capabilities: [], enableMultipleWriteLocations: true },
|
||||
{
|
||||
fabricContext: {
|
||||
@@ -399,7 +396,6 @@ describe("createDatabaseTreeNodes", () => {
|
||||
[
|
||||
"the SQL API, on Portal",
|
||||
Platform.Portal,
|
||||
false,
|
||||
{ capabilities: [], enableMultipleWriteLocations: true },
|
||||
{
|
||||
fabricContext: undefined,
|
||||
@@ -408,7 +404,6 @@ describe("createDatabaseTreeNodes", () => {
|
||||
[
|
||||
"the Cassandra API, serverless, on Hosted",
|
||||
Platform.Hosted,
|
||||
false,
|
||||
{
|
||||
capabilities: [
|
||||
{ name: CapabilityNames.EnableCassandra, description: "" },
|
||||
@@ -418,45 +413,34 @@ describe("createDatabaseTreeNodes", () => {
|
||||
{ fabricContext: undefined },
|
||||
],
|
||||
[
|
||||
"the Mongo API, with Notebooks and Phoenix features, on Emulator",
|
||||
"the Mongo API, on Emulator",
|
||||
Platform.Emulator,
|
||||
true,
|
||||
{
|
||||
capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }],
|
||||
},
|
||||
{ fabricContext: undefined },
|
||||
],
|
||||
])(
|
||||
"generates the correct tree structure for %s",
|
||||
(_, platform, isNotebookEnabled, dbAccountProperties, userContext) => {
|
||||
useNotebook.setState({ isPhoenixFeatures: isNotebookEnabled });
|
||||
updateConfigContext({ platform });
|
||||
updateUserContext({
|
||||
...userContext,
|
||||
databaseAccount: {
|
||||
properties: {
|
||||
enableMultipleWriteLocations: true,
|
||||
...dbAccountProperties,
|
||||
},
|
||||
} as unknown as DataModels.DatabaseAccount,
|
||||
});
|
||||
const nodes = createDatabaseTreeNodes(
|
||||
explorer,
|
||||
isNotebookEnabled,
|
||||
useDatabases.getState().databases,
|
||||
refreshActiveTab,
|
||||
"",
|
||||
);
|
||||
expect(nodes).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
])("generates the correct tree structure for %s", (_, platform, dbAccountProperties, userContext) => {
|
||||
updateConfigContext({ platform });
|
||||
updateUserContext({
|
||||
...userContext,
|
||||
databaseAccount: {
|
||||
properties: {
|
||||
enableMultipleWriteLocations: true,
|
||||
...dbAccountProperties,
|
||||
},
|
||||
} as unknown as DataModels.DatabaseAccount,
|
||||
});
|
||||
const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
expect(nodes).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// The above tests focused on the tree structure. The below tests focus on some core behaviors of the nodes.
|
||||
// They are not exhaustive, because exhaustive tests here require a lot of mocking and can become very brittle.
|
||||
// The goal is to cover some key behaviors like loading child nodes, opening tabs/side panels, etc.
|
||||
|
||||
it("adds new collections to database as they appear", () => {
|
||||
const nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
const giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
|
||||
expect(giganticDbNode).toBeDefined();
|
||||
expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual(["schemaCollection", "load more"]);
|
||||
@@ -488,7 +472,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
},
|
||||
} as unknown as DataModels.DatabaseAccount,
|
||||
});
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
standardDbNode = nodes.find((node) => node.label === standardDb.id());
|
||||
sharedDbNode = nodes.find((node) => node.label === sharedDb.id());
|
||||
giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
|
||||
@@ -643,7 +627,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
setup();
|
||||
|
||||
// Rebuild the nodes after changing the user/config context.
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
standardDbNode = nodes.find((node) => node.label === standardDb.id());
|
||||
standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id());
|
||||
|
||||
|
||||
@@ -137,7 +137,6 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
|
||||
|
||||
export const createDatabaseTreeNodes = (
|
||||
container: Explorer,
|
||||
isNotebookEnabled: boolean,
|
||||
databases: ViewModels.Database[],
|
||||
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
|
||||
searchText = "",
|
||||
@@ -184,9 +183,7 @@ export const createDatabaseTreeNodes = (
|
||||
database
|
||||
.collections()
|
||||
.forEach((collection: ViewModels.Collection) =>
|
||||
databaseNode.children.push(
|
||||
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
|
||||
),
|
||||
databaseNode.children.push(buildCollectionNode(database, collection, container, refreshActiveTab)),
|
||||
);
|
||||
|
||||
if (database.collectionsContinuationToken) {
|
||||
@@ -254,14 +251,13 @@ export const createDatabaseTreeNodes = (
|
||||
export const buildCollectionNode = (
|
||||
database: ViewModels.Database,
|
||||
collection: ViewModels.Collection,
|
||||
isNotebookEnabled: boolean,
|
||||
container: Explorer,
|
||||
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
|
||||
): TreeNode => {
|
||||
let children: TreeNode[];
|
||||
// Flat Tree for Fabric
|
||||
if (!isFabricMirrored()) {
|
||||
children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab);
|
||||
children = buildCollectionNodeChildren(database, collection, container, refreshActiveTab);
|
||||
}
|
||||
|
||||
const collectionNode: TreeNode = {
|
||||
@@ -310,7 +306,6 @@ export const buildCollectionNode = (
|
||||
const buildCollectionNodeChildren = (
|
||||
database: ViewModels.Database,
|
||||
collection: ViewModels.Collection,
|
||||
isNotebookEnabled: boolean,
|
||||
container: Explorer,
|
||||
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
|
||||
): TreeNode[] => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ConnectionStatusType } from "Common/Constants";
|
||||
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
||||
import create, { UseStore } from "zustand";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { useTabs } from "../hooks/useTabs";
|
||||
@@ -13,7 +11,6 @@ export interface SelectedNodeState {
|
||||
collectionId?: string,
|
||||
subnodeKinds?: ViewModels.CollectionTabKind[],
|
||||
) => boolean;
|
||||
isConnectedToContainer: () => boolean;
|
||||
}
|
||||
|
||||
export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) => ({
|
||||
@@ -61,7 +58,4 @@ export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) =>
|
||||
subnodeKinds.includes(selectedSubnodeKind)
|
||||
);
|
||||
},
|
||||
isConnectedToContainer: (): boolean => {
|
||||
return useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { initializeIcons } from "@fluentui/react";
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
@@ -83,9 +82,9 @@ const App: React.FunctionComponent = () => {
|
||||
setEncryptedToken(token);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(async (error) => {
|
||||
logError(
|
||||
`Failed to connect with connection string: ${getErrorMessage(error)}`,
|
||||
`Failed to connect with connection string: ${await (error as Response).text()}`,
|
||||
"HostedExplorer/connectWithConnectionString",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Odstranit uživatelem definovanou funkci"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplikovat kartu",
|
||||
"closeTab": "Zavřít kartu"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nová položka",
|
||||
"newDocument": "Nový dokument",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Nastavení",
|
||||
"indexingPolicy": "Zásada indexování",
|
||||
"partitionKeys": "Klíče oddílu",
|
||||
"partitionKeysPreview": "Klíče oddílů (Preview)",
|
||||
"computedProperties": "Vypočítané vlastnosti",
|
||||
"containerPolicies": "Zásady kontejneru",
|
||||
"throughputBuckets": "Kbelíky propustnosti",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Globální sekundární index",
|
||||
"maskingPolicyPreview": "Zásady maskování"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Změnit: {{partitionKeyName}}",
|
||||
"confirmCancel1": "Chystáte se zrušit následující úlohu kopírování.",
|
||||
"confirmCancel2": "Zrušením se úloha okamžitě zastaví.",
|
||||
"confirmComplete1": "Chystáte se dokončit následující úlohu kopírování.",
|
||||
"confrimComplete2": "Po dokončení a po zpracování všech čekajících dokumentů se průběžné kopírování dat zastaví. Pokud chcete zachovat integritu dat, doporučujeme před dokončením úlohy zastavit aktualizace zdrojového kontejneru.",
|
||||
"currentPartitionKey": "Aktuální {{partitionKeyName}}",
|
||||
"partitioning": "Dělení na oddíly",
|
||||
"hierarchical": "Hierarchický",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Úlohy online kopírování kontejnerů umožňují kopírovat data ze zdrojového kontejneru do cílového kontejneru rozhraní API NoSQL služby Cosmos DB pomocí kanálu změn [Všechny verze a odstranění](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Díky tomu můžou aktualizace ve zdroji pokračovat i během kopírování dat. Na konci je potřeba krátký výpadek, aby bylo možné bezpečně přepnout klientské aplikace na cílový kontejner. Další informace o [úlohách online kopírování](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Typ migrace",
|
||||
"selectContainers": {
|
||||
"description": "Vyberte zdrojový a cílový kontejner, do kterého chcete kopírovat.",
|
||||
"sourceContainerSubHeading": "Zdrojový kontejner",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Povolit online kopírování",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Ověřuje se režim kanálu změn Všechny verze a odstranění (Preview)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Povoluje se režim kanálu změn Všechny verze a odstranění (Preview)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Povoluje se online kopírování pro účet {{accountName}}..."
|
||||
"enablingOnlineCopySpinnerLabel": "Povoluje se online kopírování pro účet {{accountName}}...",
|
||||
"onlineMigrationPrerequisitesMessage": "Před pokračováním musí být povoleny požadavky na online migraci."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Potvrdit",
|
||||
"cancelButtonText": "Zrušit"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Úloha přenosu dat {{jobName}} byla zrušena",
|
||||
"completedConsoleMessage": "Úloha přenosu dat {{jobName}} byla dokončena",
|
||||
"defaultErrorMessage": "Operaci se nepovedlo dokončit.",
|
||||
"errorConsoleMessage": "Úloha přenosu dat {{jobName}} selhala: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Úloha přenosu dat {{jobName}} byla pozastavena",
|
||||
"retryConsoleMessage": "Probíhá úloha přenosu dat {{jobName}}, celkový počet: {{totalCount}}, počet zpracovaných: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Benutzerdefinierte Funktion löschen"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Doppelte Registerkarte",
|
||||
"closeTab": "Registerkarte schließen"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Neues Element",
|
||||
"newDocument": "Neues Dokument",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Einstellungen",
|
||||
"indexingPolicy": "Indizierungsrichtlinie",
|
||||
"partitionKeys": "Partitionsschlüssel",
|
||||
"partitionKeysPreview": "Partitionsschlüssel (Vorschau)",
|
||||
"computedProperties": "Berechnete Eigenschaften",
|
||||
"containerPolicies": "Containerrichtlinien",
|
||||
"throughputBuckets": "Durchsatzbuckets",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Globaler sekundärer Index",
|
||||
"maskingPolicyPreview": "Maskierungsrichtlinie"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "„{{partitionKeyName}}“ ändern",
|
||||
"confirmCancel1": "Sie sind im Begriff, den folgenden Kopierauftrag abzubrechen.",
|
||||
"confirmCancel2": "Durch das Abbrechen wird der Auftrag sofort beendet.",
|
||||
"confirmComplete1": "Sie sind im Begriff, den folgenden Kopierauftrag abzuschließen.",
|
||||
"confrimComplete2": "Nach Abschluss des Vorgangs wird die kontinuierliche Datenkopie beendet, nachdem alle ausstehenden Dokumente verarbeitet wurden. Um die Datenintegrität aufrechtzuerhalten, wird empfohlen, Aktualisierungen des Quellcontainers vor Abschluss des Auftrags zu beenden.",
|
||||
"currentPartitionKey": "Aktueller {{partitionKeyName}}",
|
||||
"partitioning": "Partitionierung",
|
||||
"hierarchical": "Hierarchisch",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Mit Onlinecontainerkopieraufträgen können Sie Daten mithilfe des Änderungsfeeds [Alle Versionen und Löschvorgänge](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) aus einem Quellcontainer in einen Cosmos DB NoSQL-API-Zielcontainer kopieren. Dadurch können Updates in der Quelle fortgesetzt werden, während Daten kopiert werden. Am Ende ist eine kurze Downtime erforderlich, um sicher über Clientanwendungen zum Zielcontainer zu wechseln. Weitere Informationen zu [Onlinekopieraufträgen](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Migrationstyp",
|
||||
"selectContainers": {
|
||||
"description": "Wählen Sie einen Quellcontainer und einen Zielcontainer aus, in den kopiert werden soll.",
|
||||
"sourceContainerSubHeading": "Quellcontainer",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Onlinekopie aktivieren",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Änderungsfeedmodus „Alle Versionen und Löschvorgänge“ (Vorschau) wird validiert ...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Änderungsfeedmodus „Alle Versionen und Löschvorgänge“ (Vorschau) wird aktiviert",
|
||||
"enablingOnlineCopySpinnerLabel": "Onlinekopie wird für Ihr Konto „{{accountName}}“ aktiviert ..."
|
||||
"enablingOnlineCopySpinnerLabel": "Onlinekopie wird für Ihr Konto „{{accountName}}“ aktiviert ...",
|
||||
"onlineMigrationPrerequisitesMessage": "Die Voraussetzungen für die Onlinemigration müssen aktiviert sein, bevor Sie fortfahren können."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Bestätigen",
|
||||
"cancelButtonText": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Datenübertragungsauftrag „{{jobName}}“ abgebrochen",
|
||||
"completedConsoleMessage": "Datenübertragungsauftrag „{{jobName}}“ abgeschlossen",
|
||||
"defaultErrorMessage": "Vorgang konnte nicht abgeschlossen werden",
|
||||
"errorConsoleMessage": "Fehler beim Datenübertragungsauftrag „{{jobName}}“: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Datenübertragungsauftrag „{{jobName}}“ angehalten",
|
||||
"retryConsoleMessage": "Der Datenübertragungsauftrag „{{jobName}}“ ist in Arbeit, Gesamtanzahl: {{totalCount}}, verarbeitete Anzahl: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@
|
||||
"off": "Off",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"cosmosDBShell": {
|
||||
"installingSdk": "Downloading and installing .NET SDK 10. First-time setup may take a few minutes.",
|
||||
"installingShell": "Installing Cosmos DB Shell...",
|
||||
"updatingShell": "Checking for Cosmos DB Shell updates...",
|
||||
"connecting": "Connecting to your Cosmos DB account..."
|
||||
},
|
||||
"splashScreen": {
|
||||
"title": {
|
||||
"default": "Welcome to Azure Cosmos DB",
|
||||
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Eliminar función definida por el usuario"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplicar pestaña",
|
||||
"closeTab": "Cerrar pestaña"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nuevo elemento",
|
||||
"newDocument": "Nuevo documento",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Configuración",
|
||||
"indexingPolicy": "Directiva de indexación",
|
||||
"partitionKeys": "Claves de partición",
|
||||
"partitionKeysPreview": "Claves de partición (versión preliminar)",
|
||||
"computedProperties": "Propiedades calculadas",
|
||||
"containerPolicies": "Directivas de contenedor",
|
||||
"throughputBuckets": "Depósitos de rendimiento",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Índice secundario global",
|
||||
"maskingPolicyPreview": "Directiva de enmascaramiento"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Cambiar {{partitionKeyName}}",
|
||||
"confirmCancel1": "Está a punto de cancelar el siguiente trabajo de copia.",
|
||||
"confirmCancel2": "La cancelación detendrá el trabajo inmediatamente.",
|
||||
"confirmComplete1": "Está a punto de completar el siguiente trabajo de copia.",
|
||||
"confrimComplete2": "Una vez completada, la copia continua de datos se detendrá después de que se procesen los documentos pendientes. Para mantener la integridad de los datos, se recomienda detener las actualizaciones del contenedor de origen antes de completar el trabajo.",
|
||||
"currentPartitionKey": "Actual {{partitionKeyName}}",
|
||||
"partitioning": "Creación de particiones",
|
||||
"hierarchical": "Jerárquico",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Los trabajos de copia de contenedores en línea permiten copiar datos de un contenedor de origen a un contenedor de destino de la API NoSQL de Cosmos DB mediante la fuente de cambios [Todas las versiones y eliminación](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Esto permite que las actualizaciones continúen en el origen mientras se copian los datos. Se requiere un breve tiempo de inactividad al final para cambiar de forma segura las aplicaciones cliente al contenedor de destino. Obtenga más información sobre [trabajos de copia online](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Tipo de migración",
|
||||
"selectContainers": {
|
||||
"description": "Seleccione un contenedor de origen y un contenedor de destino en el que copiar.",
|
||||
"sourceContainerSubHeading": "Contenedor de origen",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Habilitar copia en línea",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validando todas las versiones y elimina el modo de fuente de cambios (versión preliminar)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Habilitando todas las versiones y elimina el modo de fuente de cambios (versión preliminar)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Habilitando la copia en línea en su cuenta \"{{accountName}}\"..."
|
||||
"enablingOnlineCopySpinnerLabel": "Habilitando la copia en línea en su cuenta \"{{accountName}}\"...",
|
||||
"onlineMigrationPrerequisitesMessage": "Los requisitos previos de migración en línea deben estar habilitados antes de continuar."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Confirmar",
|
||||
"cancelButtonText": "Cancelar"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Trabajo de transferencia de datos \"{{jobName}}\" cancelado",
|
||||
"completedConsoleMessage": "Trabajo de transferencia de datos \"{{jobName}}\" completado",
|
||||
"defaultErrorMessage": "No se ha podido completar la operación",
|
||||
"errorConsoleMessage": "El trabajo de transferencia de datos \"{{jobName}}\" ha fallado: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Trabajo de transferencia de datos \"{{jobName}}\" pausado",
|
||||
"retryConsoleMessage": "Trabajo de transferencia de datos \"{{jobName}}\" en curso, recuento total: {{totalCount}}, recuento procesado: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Supprimer une fonction définie par l’utilisateur"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Dupliquer l’onglet",
|
||||
"closeTab": "Fermer un onglet"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nouvel élément",
|
||||
"newDocument": "Nouveau document",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Paramètres",
|
||||
"indexingPolicy": "Politique d'indexation",
|
||||
"partitionKeys": "Clés de partition",
|
||||
"partitionKeysPreview": "Clés de partition (aperçu)",
|
||||
"computedProperties": "Propriétés calculées",
|
||||
"containerPolicies": "Stratégies relatives aux conteneurs",
|
||||
"throughputBuckets": "Bacs de débit",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Index secondaire global",
|
||||
"maskingPolicyPreview": "Stratégie de masquage"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Modifier {{partitionKeyName}}",
|
||||
"confirmCancel1": "Vous êtes sur le point d’annuler le travail de copie suivant.",
|
||||
"confirmCancel2": "L’annulation arrêtera immédiatement le travail.",
|
||||
"confirmComplete1": "Vous êtes sur le point d’achever le travail de copie suivant.",
|
||||
"confrimComplete2": "Une fois l’opération terminée, la copie continue des données s’arrête après le traitement de tous les documents en attente. Pour préserver l’intégrité des données, nous vous recommandons d’arrêter les mises à jour du conteneur source avant d’effectuer le travail.",
|
||||
"currentPartitionKey": "{{partitionKeyName}} actuel(le)",
|
||||
"partitioning": "Partitionnement",
|
||||
"hierarchical": "Hiérarchique",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Les travaux de copie en ligne de conteneur vous permettent de copier des données d’un conteneur source vers un conteneur cible d’API NoSQL de Cosmos DB à l’aide du flux de modification [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Cela vous permet de continuer à mettre à jour la source pendant la copie des données. Un bref temps d’arrêt est nécessaire à la fin pour basculer en toute sécurité les applications clientes vers le conteneur cible. En savoir plus sur les [travaux de copie en ligne](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Type de migration",
|
||||
"selectContainers": {
|
||||
"description": "Veuillez sélectionner un conteneur source et un conteneur de destination vers lequel effectuer la copie.",
|
||||
"sourceContainerSubHeading": "Conteneur source",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Activer la copie en ligne",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validation en cours du Mode de flux de modification de toutes les versions et suppressions (préversion)",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Activation en cours du Mode de flux de modification de toutes les versions et suppressions (préversion)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Activation de la copie en ligne sur votre compte « {{accountName}} » ..."
|
||||
"enablingOnlineCopySpinnerLabel": "Activation de la copie en ligne sur votre compte « {{accountName}} » ...",
|
||||
"onlineMigrationPrerequisitesMessage": "Vous devez activer les conditions préalables à la migration en ligne avant de continuer."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Confirmer",
|
||||
"cancelButtonText": "Annuler"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Travail de transfert de données « {{jobName}} » annulé",
|
||||
"completedConsoleMessage": "Travail de transfert de données « {{jobName}} » terminé",
|
||||
"defaultErrorMessage": "L'opération n'a pas pu être terminée.",
|
||||
"errorConsoleMessage": "Nous n’avons pas pu effectuer le travail de transfert de données « {{jobName}} » : {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Travail de transfert de données « {{jobName}} » en pause",
|
||||
"retryConsoleMessage": "Travail de transfert de données « {{jobName}} » en cours, nombre total : {{totalCount}}, nombre traité : {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Felhasználó által definiált függvény törlése"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Lap duplikálása",
|
||||
"closeTab": "Lap bezárása"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Új elem",
|
||||
"newDocument": "Új dokumentum",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Beállítások",
|
||||
"indexingPolicy": "Indexelési házirend",
|
||||
"partitionKeys": "Partíciókulcsok",
|
||||
"partitionKeysPreview": "Partíciókulcsok (előzetes verzió)",
|
||||
"computedProperties": "Számított tulajdonságok",
|
||||
"containerPolicies": "Tárolószabályzatok",
|
||||
"throughputBuckets": "Átviteli sebesség gyűjtői",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Globális másodlagos index",
|
||||
"maskingPolicyPreview": "Maszkolási szabályzat"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "{{partitionKeyName}} módosítása",
|
||||
"confirmCancel1": "A következő másolási feladat megszakítására készül.",
|
||||
"confirmCancel2": "A megszakítás azonnal leállítja a feladatot.",
|
||||
"confirmComplete1": "A következő másolási feladat végrehajtására készül.",
|
||||
"confrimComplete2": "Ha elkészült, a függőben lévő dokumentumok feldolgozása után a folyamatos adatmásolás leáll. Az adatintegritás megőrzése érdekében javasoljuk, hogy a feladat végrehajtása előtt állítsa le a forrástároló frissítéseit.",
|
||||
"currentPartitionKey": "Aktuális {{partitionKeyName}}",
|
||||
"partitioning": "Particionálás",
|
||||
"hierarchical": "Hierarchikus",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Az online tárolómásolási feladatok lehetővé teszik, hogy adatokat másoljon egy forrástárolóból egy cél Cosmos DB NoSQL API-tárolóba a [Minden verzió és törlés](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) változáscsatorna használatával. Ez lehetővé teszi, hogy a frissítések az adatok másolása közben is folytatódjanak a forráson. A végén rövid leállásra van szükség ahhoz, hogy biztonságosan átválthassa az ügyfélalkalmazásokat a céltárolóra. További információ az [online másolási feladatokról](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Áttelepítés típusa",
|
||||
"selectContainers": {
|
||||
"description": "Válasszon ki egy forrástárolót és egy céltárolót, amelybe másolni szeretne.",
|
||||
"sourceContainerSubHeading": "Forrástároló",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Online másolás engedélyezése",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Az összes verzió és a törlések változáscsatorna-módjának ellenőrzése",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Az összes verzió és a törlések változáscsatorna-módjának engedélyezése...",
|
||||
"enablingOnlineCopySpinnerLabel": "Az online másolás engedélyezése a(z) „{{accountName}}” fiókban ..."
|
||||
"enablingOnlineCopySpinnerLabel": "Az online másolás engedélyezése a(z) „{{accountName}}” fiókban ...",
|
||||
"onlineMigrationPrerequisitesMessage": "A folytatás előtt engedélyezni kell az online áttelepítés előfeltételeit."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Megerősítés",
|
||||
"cancelButtonText": "Mégse"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "A(z) {{jobName}} adatátviteli feladat megszakítva",
|
||||
"completedConsoleMessage": "A(z) {{jobName}} adatátviteli feladat kész",
|
||||
"defaultErrorMessage": "A művelet nem fejezhető be",
|
||||
"errorConsoleMessage": "A(z) {{jobName}} adatátviteli feladat meghiúsult: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "A(z) {{jobName}} adatátviteli feladat szüneteltetve",
|
||||
"retryConsoleMessage": "Folyamatban van a(z) {{jobName}} adatátviteli feladat; teljes szám: {{totalCount}}, feldolgozottak száma: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Hapus Fungsi yang Ditentukan Pengguna"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplikasikan tab",
|
||||
"closeTab": "Tutup tab"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Item Baru",
|
||||
"newDocument": "Dokumen Baru",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Pengaturan",
|
||||
"indexingPolicy": "Kebijakan Pengindeksan",
|
||||
"partitionKeys": "Kunci Partisi",
|
||||
"partitionKeysPreview": "Kunci Partisi (pratinjau)",
|
||||
"computedProperties": "Properti Terkomputasi",
|
||||
"containerPolicies": "Kebijakan Kontainer",
|
||||
"throughputBuckets": "Wadah Throughput",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Indeks Sekunder Global",
|
||||
"maskingPolicyPreview": "Kebijakan Masking"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Ubah {{partitionKeyName}}",
|
||||
"confirmCancel1": "Anda akan segera membatalkan tugas penyalinan berikut.",
|
||||
"confirmCancel2": "Membatalkan akan segera menghentikan tugas.",
|
||||
"confirmComplete1": "Anda akan segera menyelesaikan tugas penyalinan berikut.",
|
||||
"confrimComplete2": "Setelah selesai, penyalinan data berkelanjutan akan berhenti setelah semua dokumen yang tertunda diproses. Untuk menjaga integritas data, sebaiknya hentikan pembaruan ke kontainer sumber sebelum menyelesaikan tugas.",
|
||||
"currentPartitionKey": "{{partitionKeyName}} saat Ini",
|
||||
"partitioning": "Pemartisian",
|
||||
"hierarchical": "Hierarkis",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Pekerjaan penyalinan kontainer online memungkinkan Anda menyalin data dari kontainer sumber ke kontainer API Cosmos DB NoSQL tujuan menggunakan umpan perubahan [Semua Versi dan Hapus](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Hal ini memungkinkan pembaruan untuk melanjutkan sumber saat data disalin. Waktu henti singkat diperlukan di akhir untuk mengalihkan aplikasi klien ke kontainer tujuan dengan aman. Pelajari selengkapnya tentang [pekerjaan penyalinan online](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Jenis migrasi",
|
||||
"selectContainers": {
|
||||
"description": "Pilih kontainer sumber dan kontainer tujuan yang akan digunakan untuk penyalinan.",
|
||||
"sourceContainerSubHeading": "Kontainer sumber",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Aktifkan Penyalinan Online",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Memvalidasi Semua versi dan penghapusan mengubah mode umpan (pratinjau)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Mengaktifkan Semua versi dan menghapus mengubah mode umpan (pratinjau)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Mengaktifkan penyalinan online di akun \"{{accountName}}\"..."
|
||||
"enablingOnlineCopySpinnerLabel": "Mengaktifkan penyalinan online di akun \"{{accountName}}\"...",
|
||||
"onlineMigrationPrerequisitesMessage": "Prasyarat migrasi online harus diaktifkan sebelum melanjutkan."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Konfirmasikan",
|
||||
"cancelButtonText": "Batal"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Tugas transfer data \"{{jobName}}\" dibatalkan",
|
||||
"completedConsoleMessage": "Tugas transfer data \"{{jobName}}\" selesai",
|
||||
"defaultErrorMessage": "Operasi tidak dapat diselesaikan",
|
||||
"errorConsoleMessage": "Tugas transfer data \"{{jobName}}\" gagal: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Tugas transfer data \"{{jobName}}\" dijeda",
|
||||
"retryConsoleMessage": "Tugas transfer data \"{{jobName}}\" sedang berlangsung, jumlah total: {{totalCount}}, jumlah diproses: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Elimina funzione definita dall'utente"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplica scheda",
|
||||
"closeTab": "Chiudi scheda"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nuovo elemento",
|
||||
"newDocument": "Nuovo documento",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Impostazioni",
|
||||
"indexingPolicy": "Criteri di indicizzazione",
|
||||
"partitionKeys": "Chiavi di partizione",
|
||||
"partitionKeysPreview": "Chiavi di partizione (anteprima)",
|
||||
"computedProperties": "Proprietà calcolate",
|
||||
"containerPolicies": "Criteri contenitore",
|
||||
"throughputBuckets": "Bucket di velocità effettiva",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Indice secondario globale",
|
||||
"maskingPolicyPreview": "Criteri di maschera"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Modifica {{partitionKeyName}}",
|
||||
"confirmCancel1": "Si sta per annullare il processo di copia seguente.",
|
||||
"confirmCancel2": "L'annullamento arresterà immediatamente il processo.",
|
||||
"confirmComplete1": "Si sta per completare il processo di copia seguente.",
|
||||
"confrimComplete2": "Al termine, la copia continua dei dati verrà interrotta dopo l'elaborazione di eventuali documenti in sospeso. Per mantenere l'integrità dei dati, è consigliabile arrestare gli aggiornamenti al contenitore di origine prima di completare il processo.",
|
||||
"currentPartitionKey": "{{partitionKeyName}} corrente",
|
||||
"partitioning": "Partizionamento",
|
||||
"hierarchical": "Gerarchico",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "I processi di copia dei contenitori online consentono di copiare i dati da un contenitore di origine a un contenitore di destinazione dell'API NoSQL di Cosmos DB usando il feed di modifiche [Tutte le versioni ed eliminazioni](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). In questo modo gli aggiornamenti possono continuare nell'origine durante la copia dei dati. Alla fine è necessario un breve periodo di inattività per passare in modo sicuro le applicazioni client al contenitore di destinazione. Altre informazioni sui [processi di copia online](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Tipo di migrazione",
|
||||
"selectContainers": {
|
||||
"description": "Selezionare un contenitore di origine e un contenitore di destinazione in cui copiare.",
|
||||
"sourceContainerSubHeading": "Contenitore di origine",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Abilita copia online",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Convalida della modalità feed di modifiche per tutte le versioni ed eliminazioni (anteprima)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Abilitazione della modalità feed di modifiche per tutte le versioni ed eliminazioni (anteprima)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Abilitazione della copia online nell'account \"{{accountName}}\"..."
|
||||
"enablingOnlineCopySpinnerLabel": "Abilitazione della copia online nell'account \"{{accountName}}\"...",
|
||||
"onlineMigrationPrerequisitesMessage": "Prima di procedere, è necessario abilitare i prerequisiti per la migrazione online."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Conferma",
|
||||
"cancelButtonText": "Annulla"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Processo di trasferimento dati \"{{jobName}}\" annullato",
|
||||
"completedConsoleMessage": "Processo di trasferimento dati \"{{jobName}}\" completato",
|
||||
"defaultErrorMessage": "Non è stato possibile completare l'operazione",
|
||||
"errorConsoleMessage": "Processo di trasferimento dati \"{{jobName}}\" non riuscito: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Processo di trasferimento dati \"{{jobName}}\" sospeso",
|
||||
"retryConsoleMessage": "Processo di trasferimento dati \"{{jobName}}\" in corso, conteggio totale: {{totalCount}}, conteggio elaborato: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "ユーザー定義関数の削除"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "タブの複製",
|
||||
"closeTab": "タブを閉じる"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "新しい項目",
|
||||
"newDocument": "新規ドキュメント",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "設定",
|
||||
"indexingPolicy": "インデックス作成ポリシー",
|
||||
"partitionKeys": "パーティション キー",
|
||||
"partitionKeysPreview": "パーティション キー (プレビュー)",
|
||||
"computedProperties": "計算されたプロパティ",
|
||||
"containerPolicies": "コンテナー ポリシー",
|
||||
"throughputBuckets": "スループット バケット",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "グローバル セカンダリ インデックス",
|
||||
"maskingPolicyPreview": "マスキング ポリシー"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "{{partitionKeyName}} の変更",
|
||||
"confirmCancel1": "次のコピー ジョブを取り消します。",
|
||||
"confirmCancel2": "取り消すと、ジョブはすぐに停止します。",
|
||||
"confirmComplete1": "次のコピー ジョブを完了します。",
|
||||
"confrimComplete2": "完了すると、保留中のドキュメントが処理された後、継続的なデータ コピーが停止します。データの整合性を維持するには、ジョブを完了する前にソース コンテナーの更新を停止することをお勧めします。",
|
||||
"currentPartitionKey": "現在の {{partitionKeyName}}",
|
||||
"partitioning": "パーティション分割",
|
||||
"hierarchical": "階層",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "オンライン コンテナー コピー ジョブでは、[All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) 変更フィードを使用して、ソース コンテナーからコピー先の Cosmos DB NoSQL API コンテナーにデータをコピーできます。これにより、データのコピー中もソースで更新を継続できます。クライアント アプリケーションをコピー先コンテナーに安全に切り替えるには、最後に短時間のダウンタイムが必要です。[online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started) についての詳細をご覧ください。"
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "移行の種類",
|
||||
"selectContainers": {
|
||||
"description": "コピー元のコンテナーとコピー先のコンテナーを選択してください。",
|
||||
"sourceContainerSubHeading": "ソース コンテナー",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "オンライン コピーを有効にする",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "すべてのバージョンを検証し、変更フィード モードを削除します (プレビュー)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "すべてのバージョンおよび削除の変更フィード モード (プレビュー) を有効にしています",
|
||||
"enablingOnlineCopySpinnerLabel": "\"{{accountName}}\" アカウントでオンライン コピーを有効にしています..."
|
||||
"enablingOnlineCopySpinnerLabel": "\"{{accountName}}\" アカウントでオンライン コピーを有効にしています...",
|
||||
"onlineMigrationPrerequisitesMessage": "続行する前に、オンライン移行の前提条件を有効にする必要があります。"
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "確認",
|
||||
"cancelButtonText": "キャンセル"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "データ転送ジョブ \"{{jobName}}\" が取り消されました",
|
||||
"completedConsoleMessage": "データ転送ジョブ \"{{jobName}}\" が完了しました",
|
||||
"defaultErrorMessage": "操作を完了できませんでした",
|
||||
"errorConsoleMessage": "データ転送ジョブ \"{{jobName}}\" に失敗しました: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "データ転送ジョブ \"{{jobName}}\" が一時停止されました",
|
||||
"retryConsoleMessage": "データ転送ジョブ \"{{jobName}}\" が進行中です。合計数: {{totalCount}}、処理された数: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "사용자 정의 함수 삭제"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "중복 탭",
|
||||
"closeTab": "탭 닫기"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "새 항목",
|
||||
"newDocument": "새 문서",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "설정",
|
||||
"indexingPolicy": "인덱싱 정책",
|
||||
"partitionKeys": "파티션 키",
|
||||
"partitionKeysPreview": "파티션 키(미리 보기)",
|
||||
"computedProperties": "계산된 속성",
|
||||
"containerPolicies": "컨테이너 정책",
|
||||
"throughputBuckets": "처리량 버킷",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "전역 보조 인덱스",
|
||||
"maskingPolicyPreview": "마스킹 정책"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "{{partitionKeyName}} 변경",
|
||||
"confirmCancel1": "다음 복사 작업을 취소하려고 합니다.",
|
||||
"confirmCancel2": "취소하면 작업이 즉시 중단됩니다.",
|
||||
"confirmComplete1": "다음 복사 작업을 완료하려고 합니다.",
|
||||
"confrimComplete2": "완료되면 보류 중인 문서가 모두 처리된 후 연속 데이터 복사가 중지됩니다. 데이터 무결성을 유지하려면 작업을 완료하기 전에 원본 컨테이너에 대한 업데이트를 중지하는 것이 좋습니다.",
|
||||
"currentPartitionKey": "현재 {{partitionKeyName}}",
|
||||
"partitioning": "분할",
|
||||
"hierarchical": "계층적",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "온라인 컨테이너 복사 작업을 사용하면 [모든 버전 및 삭제](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) 변경 피드를 통해 원본 컨테이너의 데이터를 대상 Cosmos DB NoSQL API 컨테이너로 복사할 수 있습니다. 이렇게 하면 데이터가 복사되는 동안에도 원본에서 업데이트를 계속할 수 있습니다. 클라이언트 애플리케이션을 대상 컨테이너로 안전하게 전환하려면 마지막에 잠시 가동 중지 시간이 필요합니다. [온라인 복사 작업](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)에서 자세히 알아보세요."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "마이그레이션 유형",
|
||||
"selectContainers": {
|
||||
"description": "복사할 원본 컨테이너와 대상 컨테이너를 선택하세요.",
|
||||
"sourceContainerSubHeading": "원본 컨테이너",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "온라인 복사 사용",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "모든 버전 및 삭제 변경 피드 모드(미리 보기) 유효성 검사 중...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "모든 버전 및 삭제 변경 피드 모드(미리 보기) 사용 설정 중...",
|
||||
"enablingOnlineCopySpinnerLabel": "'{{accountName}}' 계정에서 온라인 복사를 사용하도록 설정하는 중..."
|
||||
"enablingOnlineCopySpinnerLabel": "'{{accountName}}' 계정에서 온라인 복사를 사용하도록 설정하는 중...",
|
||||
"onlineMigrationPrerequisitesMessage": "계속하기 전에 온라인 마이그레이션 사전 요구 사항을 사용하도록 설정해야 합니다."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "확인",
|
||||
"cancelButtonText": "취소"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "데이터 전송 작업 \"{{jobName}}\"이(가) 취소되었습니다.",
|
||||
"completedConsoleMessage": "데이터 전송 작업 \"{{jobName}}\"이(가) 완료되었습니다.",
|
||||
"defaultErrorMessage": "작업을 완료할 수 없음",
|
||||
"errorConsoleMessage": "데이터 전송 작업 \"{{jobName}}\" 실패: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "데이터 전송 작업 \"{{jobName}}\"이(가) 일시 중지되었습니다.",
|
||||
"retryConsoleMessage": "데이터 전송 작업 \"{{jobName}}\"이 진행 중입니다. 총 개수: {{totalCount}}, 처리된 개수: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Door de gebruiker gedefinieerde functie verwijderen"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Tabblad Dupliceren",
|
||||
"closeTab": "Tabblad sluiten"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nieuw item",
|
||||
"newDocument": "Nieuw document",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Instellingen",
|
||||
"indexingPolicy": "Indexeringsbeleid",
|
||||
"partitionKeys": "Partitiesleutels",
|
||||
"partitionKeysPreview": "Partitiesleutels (preview)",
|
||||
"computedProperties": "Berekende eigenschappen",
|
||||
"containerPolicies": "Containerbeleidsregels",
|
||||
"throughputBuckets": "Doorvoerbuckets",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Globale secundaire index",
|
||||
"maskingPolicyPreview": "Beleid voor maskering"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "{{partitionKeyName}} wijzigen",
|
||||
"confirmCancel1": "U staat op het punt de volgende kopieertaak te annuleren.",
|
||||
"confirmCancel2": "Annuleren stopt de taak onmiddellijk.",
|
||||
"confirmComplete1": "U staat op het punt de volgende kopieertaak te voltooien.",
|
||||
"confrimComplete2": "Zodra dit is voltooid, stopt het continu kopiëren van gegevens nadat eventuele documenten in behandeling zijn verwerkt. Om de gegevensintegriteit te behouden, raden we u aan updates voor de broncontainer te stoppen voordat u de taak voltooit.",
|
||||
"currentPartitionKey": "Huidige {{partitionKeyName}}",
|
||||
"partitioning": "Partitionering",
|
||||
"hierarchical": "Hiërarchisch",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Met online containerkopietaken kunt u gegevens kopiëren van een broncontainer naar een bestemming Cosmos DB NoSQL API-container met behulp van de wijzigingenfeed [Alle versies en verwijderen](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. Hierdoor kunnen updates worden voortgezet op de bron terwijl gegevens worden gekopieerd. Aan het einde is een korte downtime vereist om veilig over te schakelen van clienttoepassingen naar de doelcontainer. Meer informatie over [online kopieertaken](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Migratietype",
|
||||
"selectContainers": {
|
||||
"description": "Selecteer een broncontainer en een doelcontainer waarnaar u wilt kopiëren.",
|
||||
"sourceContainerSubHeading": "Broncontainer",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Online kopiëren inschakelen",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Modus Alle versies en verwijderingen voor wijzigingenfeed (preview) valideren...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Inschakelen van Alle versies en verwijderen van de wijzigingenfeed (preview)",
|
||||
"enablingOnlineCopySpinnerLabel": "Online kopiëren inschakelen voor uw {{accountName}}-account... ..."
|
||||
"enablingOnlineCopySpinnerLabel": "Online kopiëren inschakelen voor uw {{accountName}}-account... ...",
|
||||
"onlineMigrationPrerequisitesMessage": "Vereisten voor online migratie moeten zijn ingeschakeld voordat u verdergaat."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Bevestigen",
|
||||
"cancelButtonText": "Annuleren"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Gegevensoverdrachtstaak \"{{jobName}}\" geannuleerd",
|
||||
"completedConsoleMessage": "Gegevensoverdrachtstaak \"{{jobName}}\" voltooid",
|
||||
"defaultErrorMessage": "De bewerking kan niet worden voltooid",
|
||||
"errorConsoleMessage": "Gegevensoverdrachtstaak \"{{jobName}}\" mislukt: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Gegevensoverdrachtstaak \"{{jobName}}\" onderbroken",
|
||||
"retryConsoleMessage": "Gegevensoverdrachtstaak \"{{jobName}}\" wordt uitgevoerd, totaal aantal: {{totalCount}}, verwerkt aantal: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Usuń funkcję zdefiniowaną przez użytkownika"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplikuj kartę",
|
||||
"closeTab": "Zamknij kartę"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nowy element",
|
||||
"newDocument": "Nowy dokument",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Ustawienia",
|
||||
"indexingPolicy": "Zasady indeksowania",
|
||||
"partitionKeys": "Klucze partycji",
|
||||
"partitionKeysPreview": "Klucze partycji (wersja zapoznawcza)",
|
||||
"computedProperties": "Właściwości obliczane",
|
||||
"containerPolicies": "Zasady kontenera",
|
||||
"throughputBuckets": "Zasobniki przepływności",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Globalny indeks pomocniczy",
|
||||
"maskingPolicyPreview": "Zasady maskowania"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Zmień element {{partitionKeyName}}",
|
||||
"confirmCancel1": "Zamierzasz anulować następujące zadanie kopiowania.",
|
||||
"confirmCancel2": "Anulowanie spowoduje natychmiastowe zatrzymanie zadania.",
|
||||
"confirmComplete1": "Zamierzasz ukończyć następujące zadanie kopiowania.",
|
||||
"confrimComplete2": "Po zakończeniu ciągłe kopiowanie danych zostanie zatrzymane po przetworzeniu wszystkich oczekujących dokumentów. Aby zachować integralność danych, zalecamy zatrzymanie aktualizacji kontenera źródłowego przed ukończeniem zadania.",
|
||||
"currentPartitionKey": "Bieżący {{partitionKeyName}}",
|
||||
"partitioning": "Partycjonowanie",
|
||||
"hierarchical": "Hierarchiczne",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Zadania kopiowania kontenerów online umożliwiają kopiowanie danych z kontenera źródłowego do docelowego kontenera interfejsu API NoSQL usługi Cosmos DB przy użyciu zestawienia zmian [Wszystkie wersje i usuwanie](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Umożliwia to kontynuowanie aktualizacji w źródle podczas kopiowania danych. Na końcu jest wymagany krótki przestój, aby bezpiecznie przełączyć aplikacje klienckie do kontenera docelowego. Dowiedz się więcej o [zadaniach kopiowania online](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Typ migracji",
|
||||
"selectContainers": {
|
||||
"description": "Wybierz kontener źródłowy i kontener docelowy do skopiowania.",
|
||||
"sourceContainerSubHeading": "Kontener źródłowy",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Włącz kopiowanie online",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Trwa weryfikacja wszystkich wersji i usunięć w trybie zestawienia zmian (wersja zapoznawcza)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Trwa włączanie wszystkich wersji i usunięć w trybie zestawienia zmian (wersja zapoznawcza)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Trwa włączanie kopii online na koncie „{{accountName}}”..."
|
||||
"enablingOnlineCopySpinnerLabel": "Trwa włączanie kopii online na koncie „{{accountName}}”...",
|
||||
"onlineMigrationPrerequisitesMessage": "Przed kontynuowaniem należy włączyć wymagania wstępne migracji w trybie online."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Potwierdź",
|
||||
"cancelButtonText": "Anuluj"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Anulowano zadanie transferu danych „{{jobName}}”",
|
||||
"completedConsoleMessage": "Ukończono zadanie transferu danych „{{jobName}}”",
|
||||
"defaultErrorMessage": "Nie można ukończyć operacji",
|
||||
"errorConsoleMessage": "Zadanie transferu danych „{{jobName}}” nie powiodło się: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Wstrzymano zadanie transferu danych „{{jobName}}”",
|
||||
"retryConsoleMessage": "Zadanie transferu danych „{{jobName}}” jest w toku, łączna liczba: {{totalCount}}, liczba przetworzonych danych: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Excluir Função Definida pelo Usuário"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Guia Duplicata",
|
||||
"closeTab": "Fechar guia"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Novo Item",
|
||||
"newDocument": "Novo Documento",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Configurações",
|
||||
"indexingPolicy": "Política de Indexação",
|
||||
"partitionKeys": "Chaves de Partição",
|
||||
"partitionKeysPreview": "Chaves de Partição (versão prévia)",
|
||||
"computedProperties": "Propriedades Calculadas",
|
||||
"containerPolicies": "Políticas de Contêiner",
|
||||
"throughputBuckets": "Buckets de Taxa de Transferência",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Índice Secundário Global",
|
||||
"maskingPolicyPreview": "Política de Mascaramento"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Alterar {{partitionKeyName}}",
|
||||
"confirmCancel1": "Você está prestes a cancelar o trabalho de cópia a seguir.",
|
||||
"confirmCancel2": "O cancelamento interromperá o trabalho imediatamente.",
|
||||
"confirmComplete1": "Você está prestes a concluir o seguinte trabalho de cópia.",
|
||||
"confrimComplete2": "Após a conclusão, a cópia de dados contínua será interrompida depois que todos os documentos pendentes forem processados. Para manter a integridade dos dados, recomendamos interromper as atualizações para o contêiner de origem antes de concluir o trabalho.",
|
||||
"currentPartitionKey": "{{partitionKeyName}} atual",
|
||||
"partitioning": "Particionamento",
|
||||
"hierarchical": "Hierárquico",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Os trabalhos de cópia online de contêiner permitem copiar dados de um contêiner de origem para um contêiner de destino da API NoSQL do Cosmos DB usando o feed de alterações [Todas as Versões e Excluir](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Isso permite que as atualizações continuem na fonte enquanto os dados são copiados. Um breve tempo de inatividade é necessário no final para alternar aplicativos cliente com segurança para o contêiner de destino. Saiba mais sobre [trabalhos de cópia online](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Tipo de migração",
|
||||
"selectContainers": {
|
||||
"description": "Selecione um contêiner de origem e um contêiner de destino para o qual copiar.",
|
||||
"sourceContainerSubHeading": "Contêiner de origem",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Habilitar Cópia Online",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validando Todas as versões e exclui o modo de feed de alterações (versão prévia)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Habilitando Todas as versões e excluindo o modo de feed de alterações (versão prévia)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Habilitando a cópia online em sua conta \"{{accountName}}\"..."
|
||||
"enablingOnlineCopySpinnerLabel": "Habilitando a cópia online em sua conta \"{{accountName}}\"...",
|
||||
"onlineMigrationPrerequisitesMessage": "Os pré-requisitos de migração online devem ser habilitados antes de continuar."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Confirmar",
|
||||
"cancelButtonText": "Cancelar"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Trabalho de transferência de dados \"{{jobName}}\" cancelado",
|
||||
"completedConsoleMessage": "Trabalho de transferência de dados \"{{jobName}}\" concluído",
|
||||
"defaultErrorMessage": "A operação não pôde ser concluída",
|
||||
"errorConsoleMessage": "Falha no trabalho de transferência de dados \"{{jobName}}\": {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Trabalho de transferência de dados \"{{jobName}}\" em pausa",
|
||||
"retryConsoleMessage": "Trabalho de transferência de dados \"{{jobName}}\" em andamento, contagem total: {{totalCount}}, contagem processada: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Eliminar Função Definida pelo Utilizador"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplicar separador",
|
||||
"closeTab": "Fechar separador"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Novo Item",
|
||||
"newDocument": "Novo Documento",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Definições",
|
||||
"indexingPolicy": "Política de Indexação",
|
||||
"partitionKeys": "Chaves de Partição",
|
||||
"partitionKeysPreview": "Chaves de Partição (pré-visualização)",
|
||||
"computedProperties": "Propriedades Calculadas",
|
||||
"containerPolicies": "Políticas de Contentor",
|
||||
"throughputBuckets": "Registos de Débito",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Índice secundário global",
|
||||
"maskingPolicyPreview": "Política de Mascaramento"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Alterar {{partitionKeyName}}",
|
||||
"confirmCancel1": "Está prestes a cancelar a seguinte tarefa de cópia.",
|
||||
"confirmCancel2": "O cancelamento interromperá a tarefa imediatamente.",
|
||||
"confirmComplete1": "Está prestes a concluir a seguinte tarefa de cópia.",
|
||||
"confrimComplete2": "Quando estiver concluído, a cópia contínua de dados irá parar após serem processados quaisquer documentos pendentes. Para manter a integridade dos dados, recomendamos que interrompa as atualizações no contentor de origem antes de concluir a tarefa.",
|
||||
"currentPartitionKey": "{{partitionKeyName}} atual",
|
||||
"partitioning": "Criação de Partições",
|
||||
"hierarchical": "Hierárquico",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "As tarefas de cópia de contentores online permitem-lhe copiar dados de um contentor de origem para um contentor de destino da API NoSQL do Cosmos DB usando o feed de alterações [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Isto permite que as atualizações continuem na origem enquanto os dados são copiados. É necessário um breve período de inatividade no final para mudar em segurança as aplicações cliente para o contentor de destino. Saiba mais sobre [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Tipo de migração",
|
||||
"selectContainers": {
|
||||
"description": "Selecione um contentor de origem e um contentor de destino para o qual copiar.",
|
||||
"sourceContainerSubHeading": "Contentor de origem",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Ativar Cópia Online",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "A validar o modo de feed de alterações para todas as versões e eliminações (pré-visualização)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "A ativar o modo de feed de alterações para todas as versões e eliminações (pré-visualização)...",
|
||||
"enablingOnlineCopySpinnerLabel": "A ativar a cópia online na sua conta \"{{accountName}}\"..."
|
||||
"enablingOnlineCopySpinnerLabel": "A ativar a cópia online na sua conta \"{{accountName}}\"...",
|
||||
"onlineMigrationPrerequisitesMessage": "Os pré-requisitos da migração online têm de estar ativados antes de continuar."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Confirmar",
|
||||
"cancelButtonText": "Cancelar"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Tarefa de transferência de dados \"{{jobName}}\" cancelada",
|
||||
"completedConsoleMessage": "Tarefa de transferência de dados \"{{jobName}}\" concluída",
|
||||
"defaultErrorMessage": "Não foi possível concluir a operação",
|
||||
"errorConsoleMessage": "Tarefa de transferência de dados \"{{jobName}}\" falhou: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Tarefa de transferência de dados \"{{jobName}}\" em pausa",
|
||||
"retryConsoleMessage": "Tarefa de transferência de dados \"{{jobName}}\" em curso, contagem total: {{totalCount}}, contagem processada: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Удалить пользовательскую функцию"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Дублировать вкладку",
|
||||
"closeTab": "Закрыть вкладку"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Новый элемент",
|
||||
"newDocument": "Новый документ",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Параметры",
|
||||
"indexingPolicy": "Политика индексации",
|
||||
"partitionKeys": "Ключи разделов",
|
||||
"partitionKeysPreview": "Ключи разделов (предварительная версия)",
|
||||
"computedProperties": "Вычисленные свойства",
|
||||
"containerPolicies": "Правила перевозки контейнеров",
|
||||
"throughputBuckets": "Группы пропускной способности",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Глобальный вторичный индекс",
|
||||
"maskingPolicyPreview": "Политика маскирования"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Изменить {{partitionKeyName}}",
|
||||
"confirmCancel1": "Вы собираетесь отменить следующее задание копирования.",
|
||||
"confirmCancel2": "Отмена немедленно остановит выполнение задания.",
|
||||
"confirmComplete1": "Вы собираетесь выполнить следующее задание копирования.",
|
||||
"confrimComplete2": "По выполнении непрерывное копирование данных остановится после обработки всех ожидающих документов. Для обеспечения целостности данных мы рекомендуем остановить обновление исходного контейнера до выполнения задачи.",
|
||||
"currentPartitionKey": "Текущий {{partitionKeyName}}",
|
||||
"partitioning": "Секционирование",
|
||||
"hierarchical": "Иерархический",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Онлайн-задания копирования контейнеров позволяют копировать данные из исходного контейнера в целевой контейнер API NoSQL Cosmos DB с помощью канала изменений [Все версии и удаление](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Это позволяет продолжать обновление в источнике во время копирования данных. В конце потребуется кратковременный простой, чтобы безопасно переключить клиентские приложения на целевой контейнер. Подробнее об [заданиях онлайн-копирования](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Тип миграции",
|
||||
"selectContainers": {
|
||||
"description": "Выберите исходный и целевой контейнер для копирования.",
|
||||
"sourceContainerSubHeading": "Исходный контейнер",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Включить онлайн-копирование",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Проверка режима канала изменений для всех версий и удалений (предварительная версия)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Включение режима канала изменений для всех версий и удалений (предварительная версия)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Включение онлайн-копирования для вашей учетной записи \"{{accountName}}\"..."
|
||||
"enablingOnlineCopySpinnerLabel": "Включение онлайн-копирования для вашей учетной записи \"{{accountName}}\"...",
|
||||
"onlineMigrationPrerequisitesMessage": "Перед продолжением необходимо включить предварительные условия для веб-миграции."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Подтвердить",
|
||||
"cancelButtonText": "Отмена"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Задание передачи данных \"{{jobName}}\" отменено",
|
||||
"completedConsoleMessage": "Задание передачи данных \"{{jobName}}\" выполнено",
|
||||
"defaultErrorMessage": "Не удалось завершить операцию",
|
||||
"errorConsoleMessage": "Сбой задания передачи данных \"{{jobName}}\": {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Задание передачи данных \"{{jobName}}\" приостановлено",
|
||||
"retryConsoleMessage": "Задание передачи данных \"{{jobName}}\" выполняется. Всего: {{totalCount}}, обработано: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Ta bort användardefinierad funktion"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Dubblettflik",
|
||||
"closeTab": "Stäng flik"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Nytt objekt",
|
||||
"newDocument": "Nytt dokument",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Inställningar",
|
||||
"indexingPolicy": "Indexeringspolicy",
|
||||
"partitionKeys": "Partitionsnycklar",
|
||||
"partitionKeysPreview": "Partitionsnycklar (förhandsversion)",
|
||||
"computedProperties": "Beräknade egenskaper",
|
||||
"containerPolicies": "Containerprinciper",
|
||||
"throughputBuckets": "Dataflödesbuckets",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Globalt sekundärt index",
|
||||
"maskingPolicyPreview": "Maskeringsprincip"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "Ändra {{partitionKeyName}}",
|
||||
"confirmCancel1": "Du håller på att avbryta följande kopieringsjobb.",
|
||||
"confirmCancel2": "Om du avbryter stoppas jobbet omedelbart.",
|
||||
"confirmComplete1": "Du är på väg att slutföra följande kopieringsjobb.",
|
||||
"confrimComplete2": "När detta är klart stoppas kontinuerlig datakopiering när eventuella väntande dokument har bearbetats. För att bevara dataintegriteten rekommenderar vi att du stoppar uppdateringar av källcontainern innan du slutför jobbet.",
|
||||
"currentPartitionKey": "Nuvarande {{partitionKeyName}}",
|
||||
"partitioning": "Partitionering",
|
||||
"hierarchical": "Hierarkiskt",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Med online-containerkopieringsjobb kan du kopiera data från en källcontainer till ett mål Cosmos DB NoSQL API-containern med ändringsflödet [Alla versioner och ta bort](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview). Detta gör att uppdateringar kan fortsätta på källan medan data kopieras. En kort stilleståndstid krävs i slutet för att på ett säkert sätt växla över klientprogram till målcontainern. Läs mer om [onlinekopieringsjobb](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Migreringstyp",
|
||||
"selectContainers": {
|
||||
"description": "Välj en källcontainer och en målcontainer att kopiera till.",
|
||||
"sourceContainerSubHeading": "Källcontainer",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Aktivera onlinekopiering",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Verifierar alla versioner och tar bort ändringsflödesläge (förhandsversion)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Aktiverar alla versioner och tar bort ändringsflödesläge (förhandsversion)...",
|
||||
"enablingOnlineCopySpinnerLabel": "Aktiverar onlinekopiering på ditt {{accountName}}-konto..."
|
||||
"enablingOnlineCopySpinnerLabel": "Aktiverar onlinekopiering på ditt {{accountName}}-konto...",
|
||||
"onlineMigrationPrerequisitesMessage": "Förutsättningarna för onlinemigrering måste vara aktiverade innan du fortsätter."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Bekräfta",
|
||||
"cancelButtonText": "Avbryt"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "Dataöverföringsjobbet {{jobName}} avbröts",
|
||||
"completedConsoleMessage": "Dataöverföringsjobbet {{jobName}} slutfördes",
|
||||
"defaultErrorMessage": "Det gick inte att slutföra åtgärden",
|
||||
"errorConsoleMessage": "Dataöverföringsjobbet {{jobName}} misslyckades: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "Dataöverföringsjobbet {{jobName}} pausades",
|
||||
"retryConsoleMessage": "Dataöverföringsjobbet {{jobName}} pågår, totalt antal: {{totalCount}}, bearbetat antal: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "Kullanıcı Tanımlı İşlevi Sil"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Sekmeyi çoğalt",
|
||||
"closeTab": "Sekmeyi kapat"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "Yeni Öğe",
|
||||
"newDocument": "Yeni Belge",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "Ayarlar",
|
||||
"indexingPolicy": "Dizin Oluşturma İlkesi",
|
||||
"partitionKeys": "Bölüm Anahtarları",
|
||||
"partitionKeysPreview": "Bölüm Anahtarları (önizleme)",
|
||||
"computedProperties": "Hesaplanan Özellikler",
|
||||
"containerPolicies": "Kapsayıcı İlkeleri",
|
||||
"throughputBuckets": "Aktarım Hızı Demetleri",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "Global İkincil Endeks",
|
||||
"maskingPolicyPreview": "Veri Maskeleme İlkesi"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "{{partitionKeyName}} Değiştir",
|
||||
"confirmCancel1": "Aşağıdaki kopyalama işini iptal etmek için varsınız.",
|
||||
"confirmCancel2": "İptal etme işi hemen durdurur.",
|
||||
"confirmComplete1": "Aşağıdaki kopyalama işini tamamlamak üzeresiniz.",
|
||||
"confrimComplete2": "Tamamlandığında, bekleyen belgeler işlendiğinde sürekli veri kopyalama durur. Veri bütünlüğünü korumak için işi tamamlamadan önce kaynak kapsayıcıdaki güncelleştirmeleri durdurmanızı öneririz.",
|
||||
"currentPartitionKey": "Geçerli {{partitionKeyName}}",
|
||||
"partitioning": "Bölümleme",
|
||||
"hierarchical": "Hiyerarşik",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "Çevrimiçi kapsayıcı kopyalama işleri, [Tüm Sürümler ve Sil](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) değişiklik akışını kullanarak kaynak kapsayıcıdan hedef Cosmos DB NoSQL API kapsayıcısına veri kopyalamanızı sağlar. Bu, veriler kopyalanırken kaynakta güncelleştirmelerin devam etmesini sağlar. İstemci uygulamalarını güvenli bir şekilde hedef kapsayıcıya geçirmek için sonunda kısa bir kesinti süresi gerekir. [Çevrimiçi kopyalama işleri](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started) hakkında daha fazla bilgi edinin."
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "Geçiş türü",
|
||||
"selectContainers": {
|
||||
"description": "Lütfen kopyalamak için bir kaynak kapsayıcı ve bir hedef kapsayıcı seçin.",
|
||||
"sourceContainerSubHeading": "Kaynak kapsayıcı",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "Çevrimiçi Kopyalamayı Etkinleştir",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Tüm sürümler ve silmeler için değişiklik akışı modu doğrulanıyor (önizleme)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Tüm sürümler ve silmeler için değişiklik akışı modu etkinleştiriliyor (önizleme)...",
|
||||
"enablingOnlineCopySpinnerLabel": "\"{{accountName}}\" hesabınızda çevrimiçi kopyalama etkinleştiriliyor ..."
|
||||
"enablingOnlineCopySpinnerLabel": "\"{{accountName}}\" hesabınızda çevrimiçi kopyalama etkinleştiriliyor ...",
|
||||
"onlineMigrationPrerequisitesMessage": "Devam etmeden önce çevrimiçi geçiş önkoşullarının etkinleştirilmiş olması gerekir."
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "Onayla",
|
||||
"cancelButtonText": "İptal"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "\"{{jobName}}\" veri aktarım işi iptal edildi",
|
||||
"completedConsoleMessage": "\"{{jobName}}\" veri aktarım işi tamamlandı",
|
||||
"defaultErrorMessage": "İşlem tamamlanamadı",
|
||||
"errorConsoleMessage": "\"{{jobName}}\" veri aktarım işi başarısız oldu: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "\"{{jobName}}\" veri aktarım işi duraklatıldı",
|
||||
"retryConsoleMessage": "Veri aktarım işi \"{{jobName}}\" sürüyor, toplam sayı: {{totalCount}}, işlenen sayı: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "删除用户定义函数"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "复制选项卡",
|
||||
"closeTab": "关闭选项卡"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "新建项目",
|
||||
"newDocument": "新建文档",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "设置",
|
||||
"indexingPolicy": "索引策略",
|
||||
"partitionKeys": "分区键",
|
||||
"partitionKeysPreview": "分区键(预览版)",
|
||||
"computedProperties": "计算属性",
|
||||
"containerPolicies": "容器策略",
|
||||
"throughputBuckets": "吞吐量存储桶",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "全局辅助索引",
|
||||
"maskingPolicyPreview": "掩码策略"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "更改 {{partitionKeyName}}",
|
||||
"confirmCancel1": "即将取消以下复制作业。",
|
||||
"confirmCancel2": "取消会立即停止该作业。",
|
||||
"confirmComplete1": "即将完成以下复制作业。",
|
||||
"confrimComplete2": "完成后,连续数据复制将在处理完所有待处理文档后停止。为保持数据完整性,建议在完成作业前先停止对源容器的更新。",
|
||||
"currentPartitionKey": "当前 {{partitionKeyName}}",
|
||||
"partitioning": "分区",
|
||||
"hierarchical": "分层",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "借助联机容器复制作业,可以使用 [所有版本和删除](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) 更改源将数据从源容器复制到目标 Cosmos DB NoSQL API 容器。此操作允许在复制数据时在源上继续更新。最后,需要短暂停机才能安全地将客户端应用程序切换到目标容器。详细了解 [联机复制作业](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)。"
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "迁移类型",
|
||||
"selectContainers": {
|
||||
"description": "请选择源容器和要复制到的目标容器。",
|
||||
"sourceContainerSubHeading": "源容器",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "启用联机复制",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "正在验证所有版本和删除更改源模式(预览)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "正在启用所有版本和删除更改源模式(预览)...",
|
||||
"enablingOnlineCopySpinnerLabel": "正在“{{accountName}}”帐户上启用联机复制..."
|
||||
"enablingOnlineCopySpinnerLabel": "正在“{{accountName}}”帐户上启用联机复制...",
|
||||
"onlineMigrationPrerequisitesMessage": "必须先启用联机迁移先决条件,然后才能继续。"
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "确认",
|
||||
"cancelButtonText": "取消"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "数据传输作业“{{jobName}}”已取消",
|
||||
"completedConsoleMessage": "数据传输作业“{{jobName}}”已完成",
|
||||
"defaultErrorMessage": "无法完成操作",
|
||||
"errorConsoleMessage": "数据传输作业“{{jobName}}”失败: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "数据传输作业“{{jobName}}”已暂停",
|
||||
"retryConsoleMessage": "数据传输作业“{{jobName}}”正在进行中,总计数: {{totalCount}},已处理计数: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,10 @@
|
||||
"deleteUdf": "刪除使用者定義的函式"
|
||||
},
|
||||
"tabs": {
|
||||
"tabMenu": {
|
||||
"duplicateTab": "重複的索引標籤",
|
||||
"closeTab": "關閉索引標籤"
|
||||
},
|
||||
"documents": {
|
||||
"newItem": "新增項目",
|
||||
"newDocument": "新增文件",
|
||||
@@ -760,11 +764,10 @@
|
||||
"settings": "設定",
|
||||
"indexingPolicy": "編製索引原則",
|
||||
"partitionKeys": "資料分割索引鍵",
|
||||
"partitionKeysPreview": "磁碟分割索引鍵 (預覽)",
|
||||
"computedProperties": "計算屬性",
|
||||
"containerPolicies": "容器原則",
|
||||
"throughputBuckets": "輸送量貯體",
|
||||
"globalSecondaryIndexPreview": "Global Secondary Index",
|
||||
"globalSecondaryIndexPreview": "全域從屬索引",
|
||||
"maskingPolicyPreview": "遮罩原則"
|
||||
},
|
||||
"mongoNotifications": {
|
||||
@@ -895,6 +898,10 @@
|
||||
},
|
||||
"partitionKeyEditor": {
|
||||
"changePartitionKey": "變更 {{partitionKeyName}}",
|
||||
"confirmCancel1": "您即將取消下列複製工作。",
|
||||
"confirmCancel2": "取消將立即停止作業。",
|
||||
"confirmComplete1": "您即將完成下列複製作業。",
|
||||
"confrimComplete2": "完成後,持續資料複製會在處理完任何擱置的文件後停止。為了維持資料完整性,建議您在完成作業前先停止對來源容器的更新。",
|
||||
"currentPartitionKey": "目前的 {{partitionKeyName}}",
|
||||
"partitioning": "資料分割",
|
||||
"hierarchical": "階層式",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"description": "線上容器複製工作可讓您使用 [所有版本和刪除](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) 變更摘要,將資料從來源容器複製到目的地 Cosmos DB NoSQL API 容器。這可讓您在複製資料的同時,繼續對來源進行更新。最後需要短暫停機,才能安全地將用戶端應用程式切換到目的地容器。深入了解 [線上複製工作](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)。"
|
||||
}
|
||||
},
|
||||
"migrationTypeTitle": "移轉類型",
|
||||
"selectContainers": {
|
||||
"description": "請選取來源容器和要複製到的目的地容器。",
|
||||
"sourceContainerSubHeading": "來源容器",
|
||||
@@ -1117,7 +1125,8 @@
|
||||
"buttonText": "啟用線上複製",
|
||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "驗證所有版本與刪除的變更摘要模式 (預覽)...",
|
||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "啟用所有版本與刪除的變更摘要模式 (預覽)...",
|
||||
"enablingOnlineCopySpinnerLabel": "正在啟用您「{{accountName}}」帳戶的線上複製..."
|
||||
"enablingOnlineCopySpinnerLabel": "正在啟用您「{{accountName}}」帳戶的線上複製...",
|
||||
"onlineMigrationPrerequisitesMessage": "必須先啟用線上移轉必要條件,才能繼續。"
|
||||
},
|
||||
"monitorJobs": {
|
||||
"columns": {
|
||||
@@ -1152,6 +1161,16 @@
|
||||
"confirmButtonText": "確認",
|
||||
"cancelButtonText": "取消"
|
||||
}
|
||||
},
|
||||
"dataTransfers": {
|
||||
"polling": {
|
||||
"cancelConsoleMessage": "資料傳輸作業「{{jobName}}」已取消",
|
||||
"completedConsoleMessage": "資料傳輸作業「{{jobName}}」已完成",
|
||||
"defaultErrorMessage": "作業無法完成",
|
||||
"errorConsoleMessage": "資料傳輸作業「{{jobName}}」已失敗: {{errorMessage}}",
|
||||
"pauseConsoleMessage": "資料傳輸作業「{{jobName}}」已暫停",
|
||||
"retryConsoleMessage": "資料傳輸作業「{{jobName}}」進行中,總計數: {{totalCount}},已處理計數: {{processedCount}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,6 @@ import { ErrorBoundary } from "./Explorer/ErrorBoundary";
|
||||
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
|
||||
import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
|
||||
import { CommandBar } from "./Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import "./Explorer/Menus/CommandBar/ConnectionStatusComponent.less";
|
||||
import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less";
|
||||
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
|
||||
import { NotificationConsole } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import "./Explorer/Panes/PanelComponent.less";
|
||||
|
||||
@@ -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("../../../Common/PortalBackendClient");
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { extractFeatures } from "Platform/Hosted/extractFeatures";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import React from "react";
|
||||
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
|
||||
import { ConnectExplorer } from "./ConnectExplorer";
|
||||
|
||||
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<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", () => {
|
||||
const connectionString = "fakeConnectionString";
|
||||
const login = jest.fn();
|
||||
@@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
|
||||
|
||||
updateUserContext({ features: oldFeatures });
|
||||
});
|
||||
|
||||
it("rejects an unrecognized connection string before token exchange", async () => {
|
||||
render(
|
||||
<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).toHaveBeenCalledWith("not-a-valid-connection-string");
|
||||
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 { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||
import { userContext } from "UserContext";
|
||||
import * as React from "react";
|
||||
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
|
||||
import ErrorImage from "../../../../images/error.svg";
|
||||
import { AuthType } from "../../../AuthType";
|
||||
import { HttpStatusCodes } from "../../../Common/Constants";
|
||||
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
|
||||
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
||||
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
||||
@@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
}: Props) => {
|
||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
|
||||
|
||||
return (
|
||||
@@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
{isFormVisible && enableConnectionStringLogin ? (
|
||||
<form
|
||||
id="connectWithConnectionString"
|
||||
aria-busy={isConnecting}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
setIsBlockedByFirewall(false);
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
||||
setErrorMessage(
|
||||
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
setErrorMessage(getErrorMessage(error as Error));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResourceTokenConnectionString(connectionString)) {
|
||||
setAuthType(AuthType.ResourceToken);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isResourceTokenConnectionString(connectionString)) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
if (isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||
setAccountMetadata(metadata);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mongo and Cassandra go through the Portal Backend
|
||||
const encryptedToken = await fetchEncryptedToken(connectionString);
|
||||
setEncryptedToken(encryptedToken);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorDetails = await (error as Response).text();
|
||||
|
||||
const encryptedToken = await fetchEncryptedToken(connectionString);
|
||||
setEncryptedToken(encryptedToken);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
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>
|
||||
@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
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>
|
||||
{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">
|
||||
<input className="filterbtnstyle" type="submit" value="Connect" />
|
||||
<input
|
||||
className="filterbtnstyle"
|
||||
type="submit"
|
||||
value={isConnecting ? "Connecting..." : "Connect"}
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
</p>
|
||||
<p className="switchConnectTypeText" onClick={login}>
|
||||
Sign In with Azure Account
|
||||
|
||||
@@ -55,41 +55,19 @@
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
|
||||
font-style: italic;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-left: 4px;
|
||||
vertical-align: top;
|
||||
.connectExplorerContainer .connectExplorer .connectErrorMessageBar {
|
||||
/* Matches the width of the connection string input so the form stays balanced. */
|
||||
width: 308px;
|
||||
margin: 0px auto 8px auto;
|
||||
text-align: left;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails {
|
||||
visibility: visible;
|
||||
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails {
|
||||
/* Service messages are diagnostics containing unbroken URIs and ids. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
|
||||
bottom: 24px;
|
||||
width: 165px;
|
||||
visibility: hidden;
|
||||
background-color: #393939;
|
||||
color: #ffffff;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: -10px;
|
||||
padding: 6px;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after {
|
||||
border-width: 10px 10px 0px 10px;
|
||||
bottom: -8px;
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
border-style: solid;
|
||||
left: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-color: #3b3b3b transparent;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filterbtnstyle {
|
||||
|
||||
@@ -1,19 +1,75 @@
|
||||
import { configContext, updateConfigContext } from "../../../ConfigContext";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import { parseConnectionString } from "./ConnectionStringParser";
|
||||
import {
|
||||
buildEndpointsRegex,
|
||||
dnsZoneAlternation,
|
||||
parseConnectionString,
|
||||
selectEndpointZone,
|
||||
} from "./ConnectionStringParser";
|
||||
|
||||
describe("ConnectionStringParser", () => {
|
||||
const mockAccountName = "Test";
|
||||
const mockMasterKey = "some-key";
|
||||
|
||||
it("should parse a valid sql account connection string", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
|
||||
);
|
||||
// The shape of each api's connection string, parameterized by the dns zone the account sits in. What
|
||||
// these tests are about is the zones, so keeping the shapes here stops them being restated once per zone.
|
||||
const buildConnectionString = {
|
||||
sql: (zone: string) => `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};`,
|
||||
mongo: (zone: string) => `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${zone}:10255`,
|
||||
// A cassandra connection string can name the account host under either AccountEndpoint or HostName.
|
||||
cassandra: (zone: string, hostKey = "AccountEndpoint") =>
|
||||
`${hostKey}=${mockAccountName}.${zone};AccountKey=${mockMasterKey};`,
|
||||
table: (zone: string) =>
|
||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${zone}:443/;`,
|
||||
graph: (zone: string) =>
|
||||
`AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
|
||||
};
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
||||
expect(metadata.apiEndpoint).toBeUndefined();
|
||||
const connectionStringsByApiKind: Record<DataModels.ApiKind, 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.apiKind).toBe(Number(apiKind));
|
||||
});
|
||||
});
|
||||
|
||||
// The parameterized tests below iterate the zone lists, so removing a zone would silently shrink the
|
||||
// suite rather than fail it. Pin the expected contents so that stays visible in review.
|
||||
it("should support the expected dns zones", () => {
|
||||
expect(configContext.SQL_DNS_ZONES).toEqual([
|
||||
"documents.azure.com",
|
||||
"sql.cosmosdb.azure.com",
|
||||
"sql.cosmos.azure.com",
|
||||
"sqlx.cosmos.azure.com",
|
||||
"documents-staging.windows-ppe.net",
|
||||
"sql.cosmosdb.windows-ppe.net",
|
||||
"sql.cosmos.windows-ppe.net",
|
||||
"sqlx.cosmos.windows-ppe.net",
|
||||
]);
|
||||
expect(configContext.MONGO_DNS_ZONES).toEqual(["documents.azure.com", "documents-staging.windows-ppe.net"]);
|
||||
expect(configContext.MONGO_COMPUTE_DNS_ZONES).toEqual(["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"]);
|
||||
expect(configContext.CASSANDRA_DNS_ZONES).toEqual([
|
||||
"cassandra.cosmosdb.azure.com",
|
||||
"cassandra.cosmos.azure.com",
|
||||
"cassandra.cosmosdb.windows-ppe.net",
|
||||
"cassandra.cosmos.windows-ppe.net",
|
||||
]);
|
||||
expect(configContext.TABLE_DNS_ZONES).toEqual([
|
||||
"table.cosmosdb.azure.com",
|
||||
"table.cosmos.azure.com",
|
||||
"table.cosmosdb.windows-ppe.net",
|
||||
"table.cosmos.windows-ppe.net",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should keep the document endpoint given by the connection string", () => {
|
||||
@@ -26,28 +82,99 @@ describe("ConnectionStringParser", () => {
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
|
||||
});
|
||||
|
||||
it("should parse a valid mongo account connection string", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
|
||||
);
|
||||
it.each(configContext.SQL_DNS_ZONES)(
|
||||
"should parse a sql account connection string using the %s zone",
|
||||
(dnsZone: string) => {
|
||||
const metadata = parseConnectionString(buildConnectionString.sql(dnsZone));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
|
||||
});
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`);
|
||||
expect(metadata.apiEndpoint).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("should parse a valid compute mongo account connection string", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
|
||||
);
|
||||
it.each(configContext.MONGO_DNS_ZONES)(
|
||||
"should parse a mongo account connection string using the %s zone",
|
||||
(dnsZone: string) => {
|
||||
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
|
||||
});
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(configContext.MONGO_COMPUTE_DNS_ZONES)(
|
||||
"should parse a compute mongo account connection string using the %s zone",
|
||||
(dnsZone: string) => {
|
||||
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
["AccountEndpoint", "HostName"].flatMap((hostKey) =>
|
||||
configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [hostKey, dnsZone]),
|
||||
),
|
||||
)(
|
||||
"should parse a cassandra account connection string using %s and the %s zone",
|
||||
(hostKey: string, dnsZone: string) => {
|
||||
const metadata = parseConnectionString(buildConnectionString.cassandra(dnsZone, hostKey));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(configContext.TABLE_DNS_ZONES)(
|
||||
"should parse a table account connection string using the %s zone",
|
||||
(dnsZone: string) => {
|
||||
const metadata = parseConnectionString(buildConnectionString.table(dnsZone));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
||||
expect(metadata.apiEndpoint).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
// A Mongo, Cassandra or Table connection string names the account in its own api's dns zone rather than
|
||||
// giving the document endpoint, so the document endpoint that data plane operations go through is built
|
||||
// from the account name, under a zone of the same kind as the one that matched.
|
||||
const publicDocumentEndpoint = `https://${mockAccountName}.documents.azure.com:443/`;
|
||||
const ppeDocumentEndpoint = `https://${mockAccountName}.documents-staging.windows-ppe.net:443/`;
|
||||
const expectedDocumentEndpointByZone: Record<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", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
|
||||
);
|
||||
const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com"));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
|
||||
@@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => {
|
||||
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
|
||||
});
|
||||
|
||||
it("should parse a valid table account connection string", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
|
||||
);
|
||||
it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
|
||||
const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net"));
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
||||
// Table data operations go through the document endpoint, which is constructed from the account name.
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
||||
expect(metadata.apiEndpoint).toBeUndefined();
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
|
||||
// The constructed endpoint has to match the kind of zone the document endpoint we matched came from.
|
||||
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.windows-ppe.net:443`);
|
||||
});
|
||||
|
||||
it("should parse a valid table account connection string using the cosmos.azure.com zone", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`,
|
||||
);
|
||||
it("should reject a connection string when no DNS zone matches the account", () => {
|
||||
const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES;
|
||||
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] });
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
||||
expect(metadata.apiEndpoint).toBeUndefined();
|
||||
try {
|
||||
const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net"));
|
||||
|
||||
// The account key travels to the constructed document endpoint, so a config carrying no PPE zone
|
||||
// has to fail on a PPE account rather than fall back to a zone the account does not own.
|
||||
expect(metadata).toBe(undefined);
|
||||
} finally {
|
||||
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: originalZones });
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse a valid cassandra account connection string", () => {
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
|
||||
);
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
|
||||
it.each([
|
||||
buildConnectionString.sql("documents.azure.com.attacker.example"),
|
||||
buildConnectionString.mongo("documents.azure.com.attacker.example"),
|
||||
buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"),
|
||||
buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"),
|
||||
buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"),
|
||||
])("should not accept a host that only begins with a known zone: %s", (connectionString: string) => {
|
||||
// The zone list is what keeps the account key from being sent somewhere arbitrary, so a host that
|
||||
// appends to an allowed zone must not pass as that zone.
|
||||
expect(parseConnectionString(connectionString)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should fail to parse an invalid connection string", () => {
|
||||
@@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => {
|
||||
|
||||
expect(metadata).toBe(undefined);
|
||||
});
|
||||
|
||||
describe("dnsZoneAlternation", () => {
|
||||
it("should escape the dots in a zone", () => {
|
||||
expect(dnsZoneAlternation(["documents.azure.com"])).toBe("(documents\\.azure\\.com)(?=[:/\\s]|$)");
|
||||
});
|
||||
|
||||
it("should join multiple zones into a single alternation", () => {
|
||||
expect(dnsZoneAlternation(["a.example", "b.test"])).toBe("(a\\.example|b\\.test)(?=[:/\\s]|$)");
|
||||
});
|
||||
|
||||
it("should not let the dots match arbitrary characters", () => {
|
||||
// An unescaped dot would make the zone list match hosts that only resemble a real zone.
|
||||
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
|
||||
|
||||
expect(regex.test("documents.azure.com")).toBe(true);
|
||||
expect(regex.test("documentsXazure.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should capture the zone that matched", () => {
|
||||
const regex = RegExp(dnsZoneAlternation(["a.example", "b.test"]));
|
||||
|
||||
expect("account.b.test".match(regex)[1]).toBe("b.test");
|
||||
});
|
||||
|
||||
it("should require the zone to run to the end of the host", () => {
|
||||
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
|
||||
|
||||
expect(regex.test("account.documents.azure.com")).toBe(true);
|
||||
expect(regex.test("account.documents.azure.com:443/")).toBe(true);
|
||||
expect(regex.test("account.documents.azure.com/")).toBe(true);
|
||||
// Without this the zone list stops being an allowlist, since anything can be appended to a zone.
|
||||
expect(regex.test("account.documents.azure.com.attacker.example")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEndpointsRegex", () => {
|
||||
it("should build a pattern for every api matched by dns zone", () => {
|
||||
expect(Object.keys(buildEndpointsRegex())).toEqual(["sql", "mongo", "mongoCompute", "cassandra", "table"]);
|
||||
});
|
||||
|
||||
it("should build a cassandra pattern for each supported key", () => {
|
||||
const { cassandra } = buildEndpointsRegex();
|
||||
|
||||
expect(cassandra).toHaveLength(2);
|
||||
expect(cassandra[0]).toContain("AccountEndpoint=");
|
||||
expect(cassandra[1]).toContain("HostName=");
|
||||
});
|
||||
|
||||
it("should build each pattern from its own zone list", () => {
|
||||
// The patterns are near identical, so a zone list wired to the wrong api would be easy to miss in
|
||||
// review and would let an account of one api be parsed as another.
|
||||
const { sql, mongo, mongoCompute, cassandra, table } = buildEndpointsRegex();
|
||||
|
||||
expect(sql).toContain(dnsZoneAlternation(configContext.SQL_DNS_ZONES));
|
||||
expect(mongo).toContain(dnsZoneAlternation(configContext.MONGO_DNS_ZONES));
|
||||
expect(mongoCompute).toContain(dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES));
|
||||
cassandra.forEach((pattern) => expect(pattern).toContain(dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)));
|
||||
expect(table).toContain(dnsZoneAlternation(configContext.TABLE_DNS_ZONES));
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectEndpointZone", () => {
|
||||
const nonPpeZone = "documents.azure.com";
|
||||
const ppeZone = "documents-staging.windows-ppe.net";
|
||||
|
||||
it("should pick the ppe zone for a ppe account", () => {
|
||||
expect(selectEndpointZone([nonPpeZone, ppeZone], true)).toBe(ppeZone);
|
||||
});
|
||||
|
||||
it("should pick the non ppe zone for a non ppe account", () => {
|
||||
expect(selectEndpointZone([nonPpeZone, ppeZone], false)).toBe(nonPpeZone);
|
||||
});
|
||||
|
||||
it("should not depend on the order of the zones", () => {
|
||||
expect(selectEndpointZone([ppeZone, nonPpeZone], true)).toBe(ppeZone);
|
||||
expect(selectEndpointZone([ppeZone, nonPpeZone], false)).toBe(nonPpeZone);
|
||||
});
|
||||
|
||||
it("should return undefined when no zone matches the kind of account", () => {
|
||||
// Sovereign configs carry no ppe zone, and a ppe only config carries no non ppe zone.
|
||||
expect(selectEndpointZone([nonPpeZone], true)).toBeUndefined();
|
||||
expect(selectEndpointZone([ppeZone], false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for an empty zone list", () => {
|
||||
expect(selectEndpointZone([], false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,80 @@
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import { configContext } from "../../../ConfigContext";
|
||||
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
|
||||
|
||||
// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's
|
||||
// accessinputmetadata API constructs from the account name when the connection string does not already
|
||||
// contain the endpoint.
|
||||
const DocumentEndpointZone = "documents.azure.com";
|
||||
const GremlinEndpointZone = "gremlin.cosmos.azure.com";
|
||||
const PpeDnsSuffix = "windows-ppe.net";
|
||||
const DnsPort = "443";
|
||||
|
||||
// Match on a label boundary so a zone like "notwindows-ppe.net" is not taken for a PPE zone.
|
||||
const isPpeZone = (zone: string): boolean => zone === PpeDnsSuffix || zone.endsWith(`.${PpeDnsSuffix}`);
|
||||
|
||||
// Picks the DNS zone matching the kind of account the connection string came from, since a PPE
|
||||
// account's endpoints sit under PPE zones and every other account's do not.
|
||||
export const selectEndpointZone = (zones: ReadonlyArray<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 {
|
||||
if (connectionString) {
|
||||
try {
|
||||
const accessInput = {} as AccessInputMetadata;
|
||||
const connectionStringParts = connectionString.split(";");
|
||||
const endpointsRegex = buildEndpointsRegex();
|
||||
// Endpoints we build from the account name have to match the kind of zone the connection string
|
||||
// actually matched, since PPE accounts and other accounts do not share zones.
|
||||
let isPpeAccount = false;
|
||||
|
||||
connectionStringParts.forEach((connectionStringPart: string) => {
|
||||
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
|
||||
if (RegExp(endpointsRegex.sql).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(endpointsRegex.sql);
|
||||
accessInput.accountName = matches[1];
|
||||
accessInput.apiKind = ApiKind.SQL;
|
||||
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
|
||||
// given instead of rebuilding it from the account name.
|
||||
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
|
||||
isPpeAccount = isPpeZone(matches[2]);
|
||||
} else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(endpointsRegex.mongo);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
accessInput.apiKind = ApiKind.MongoDB;
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
|
||||
isPpeAccount = isPpeZone(matches[3]);
|
||||
} else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
accessInput.apiKind = ApiKind.MongoDBCompute;
|
||||
} else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
|
||||
Constants.EndpointsRegex.cassandra.forEach((regex) => {
|
||||
if (RegExp(regex).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(regex)[1];
|
||||
isPpeAccount = isPpeZone(matches[3]);
|
||||
} else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
|
||||
endpointsRegex.cassandra.forEach((regex) => {
|
||||
const matches: string[] = connectionStringPart.match(regex);
|
||||
if (matches) {
|
||||
accessInput.accountName = matches[1];
|
||||
accessInput.apiKind = ApiKind.Cassandra;
|
||||
isPpeAccount = isPpeZone(matches[2]);
|
||||
}
|
||||
});
|
||||
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
|
||||
} else if (RegExp(endpointsRegex.table).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(endpointsRegex.table);
|
||||
accessInput.accountName = matches[1];
|
||||
accessInput.apiKind = ApiKind.Table;
|
||||
isPpeAccount = isPpeZone(matches[2]);
|
||||
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
|
||||
accessInput.apiKind = ApiKind.Graph;
|
||||
}
|
||||
@@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
|
||||
// operations go through has to be derived from the account name. Gremlin accounts additionally
|
||||
// need the Gremlin endpoint, which is never part of the connection string.
|
||||
// A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather
|
||||
// than giving the document endpoint, so the document endpoint that data plane operations go through
|
||||
// has to be built from the account name. SQL and Gremlin strings carry it and take it as given.
|
||||
// Gremlin additionally needs the Gremlin endpoint, which is never part of the connection string.
|
||||
if (accessInput.accountName) {
|
||||
if (accessInput.apiKind === ApiKind.Table) {
|
||||
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
|
||||
if (
|
||||
accessInput.apiKind === ApiKind.Table ||
|
||||
accessInput.apiKind === ApiKind.MongoDB ||
|
||||
accessInput.apiKind === ApiKind.MongoDBCompute ||
|
||||
accessInput.apiKind === ApiKind.Cassandra
|
||||
) {
|
||||
const documentEndpointZone = selectEndpointZone(configContext.DOCUMENT_ENDPOINT_ZONES, isPpeAccount);
|
||||
if (!documentEndpointZone) {
|
||||
return undefined;
|
||||
}
|
||||
accessInput.documentEndpoint = `https://${accessInput.accountName}.${documentEndpointZone}:${DnsPort}/`;
|
||||
} else if (accessInput.apiKind === ApiKind.Graph) {
|
||||
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
|
||||
const gremlinEndpointZone = selectEndpointZone(configContext.GREMLIN_ENDPOINT_ZONES, isPpeAccount);
|
||||
if (!gremlinEndpointZone) {
|
||||
return undefined;
|
||||
}
|
||||
accessInput.apiEndpoint = `${accessInput.accountName}.${gremlinEndpointZone}:${DnsPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { extractFeatures, hasFlag } from "./extractFeatures";
|
||||
|
||||
describe("extractFeatures", () => {
|
||||
it("enables Cosmos DB Shell by default", () => {
|
||||
expect(extractFeatures(new URLSearchParams()).enableCosmosDBShell).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["feature.enableCosmosDBShell", "enableCosmosDBShell"])(
|
||||
"respects explicit Cosmos DB Shell overrides via %s",
|
||||
(key) => {
|
||||
expect(extractFeatures(new URLSearchParams({ [key]: "false" })).enableCosmosDBShell).toBe(false);
|
||||
expect(extractFeatures(new URLSearchParams({ [key]: "true" })).enableCosmosDBShell).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("correctly detects feature flags in a case insensitive manner", () => {
|
||||
const url = "https://localhost:10001/12345/notebook";
|
||||
const token = "super secret";
|
||||
|
||||
@@ -105,7 +105,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
||||
enableContainerCopy: "true" === get("enablecontainercopy"),
|
||||
enableRestoreContainer: "true" === get("enablerestorecontainer"),
|
||||
enableCloudShell: true,
|
||||
enableCosmosDBShell: "true" === get("enablecosmosdbshell"),
|
||||
enableCosmosDBShell: "true" === get("enablecosmosdbshell", "true"),
|
||||
mongoDisableNativeAuth: "true" === get("mongodisablenativeauth"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +88,9 @@ export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = [
|
||||
...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []),
|
||||
];
|
||||
|
||||
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = ["https://dataexplorer-preview.azurewebsites.net/"];
|
||||
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = [
|
||||
"https://dataexplorer-preview.portal.cosmos.azure.com/",
|
||||
];
|
||||
|
||||
export const allowedJunoOrigins: ReadonlyArray<string> = [
|
||||
JunoEndpoints.Test,
|
||||
|
||||
@@ -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(() => {
|
||||
const effect = async () => {
|
||||
if (platform) {
|
||||
//Updating phoenix feature flags for MPAC based of config context
|
||||
if (configContext.isPhoenixEnabled === true) {
|
||||
userContext.features.phoenixNotebooks = true;
|
||||
userContext.features.phoenixFeatures = true;
|
||||
}
|
||||
|
||||
let explorer: Explorer;
|
||||
try {
|
||||
if (platform === Platform.Hosted) {
|
||||
@@ -1010,15 +1004,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
|
||||
if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) {
|
||||
userContext.features.partitionKeyDefault2 = true;
|
||||
}
|
||||
if (inputs.flights.indexOf(Flights.PhoenixNotebooks) !== -1) {
|
||||
userContext.features.phoenixNotebooks = true;
|
||||
}
|
||||
if (inputs.flights.indexOf(Flights.PhoenixFeatures) !== -1) {
|
||||
userContext.features.phoenixFeatures = true;
|
||||
}
|
||||
if (inputs.flights.indexOf(Flights.NotebooksDownBanner) !== -1) {
|
||||
userContext.features.notebooksDownBanner = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle initial theme from portal
|
||||
|
||||
@@ -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";
|
||||
import create, { UseStore } from "zustand";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { CollectionTabKind } from "../Contracts/ViewModels";
|
||||
import TabsBase from "../Explorer/Tabs/TabsBase";
|
||||
|
||||
export interface TabsState {
|
||||
@@ -27,7 +26,6 @@ export interface TabsState {
|
||||
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void;
|
||||
closeTabsByComparator: (comparator: (tab: TabsBase) => boolean) => void;
|
||||
closeTab: (tab: TabsBase) => void;
|
||||
closeAllNotebookTabs: (hardClose: boolean) => void;
|
||||
openAndActivateReactTab: (tabKind: ReactTabKind) => void;
|
||||
closeReactTab: (tabKind: ReactTabKind) => void;
|
||||
setIsTabExecuting: (state: boolean) => void;
|
||||
@@ -132,33 +130,6 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
|
||||
|
||||
get().persistTabsState();
|
||||
},
|
||||
closeAllNotebookTabs: (): void => {
|
||||
const isNotebook = (tabKind: CollectionTabKind): boolean => {
|
||||
if (
|
||||
tabKind === CollectionTabKind.Notebook ||
|
||||
tabKind === CollectionTabKind.NotebookV2 ||
|
||||
tabKind === CollectionTabKind.SchemaAnalyzer ||
|
||||
tabKind === CollectionTabKind.Terminal
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tabList = get().openedTabs;
|
||||
if (tabList && tabList.length > 0) {
|
||||
tabList.forEach((tab: TabsBase) => {
|
||||
const tabKind: CollectionTabKind = tab.tabKind;
|
||||
if (tabKind && isNotebook(tabKind)) {
|
||||
tab.onCloseTabButtonClick();
|
||||
}
|
||||
});
|
||||
|
||||
if (get().openedTabs.length === 0 && !isFabricMirrored()) {
|
||||
set({ activeTab: undefined, activeReactTab: undefined });
|
||||
}
|
||||
}
|
||||
},
|
||||
openAndActivateReactTab: (tabKind: ReactTabKind) => {
|
||||
if (get().openedReactTabs.indexOf(tabKind) === -1) {
|
||||
set((state) => ({
|
||||
|
||||
@@ -6,6 +6,9 @@ import enableHooks from "jest-react-hooks-shallow";
|
||||
import { TextDecoder, TextEncoder } from "util";
|
||||
import i18n from "./i18n";
|
||||
import enResources from "./Localization/en/Resources.json";
|
||||
|
||||
jest.mock("web-vitals");
|
||||
|
||||
configure({ adapter: new Adapter() });
|
||||
initializeIcons();
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export enum TestAccount {
|
||||
SQLReadOnly = "SQLReadOnly",
|
||||
SQLContainerCopyOnly = "SQLContainerCopyOnly",
|
||||
SQLConnectionString = "SQLConnectionString",
|
||||
SQLConnectionStringPublicNetworkAccessDisabled = "SQLConnectionStringPublicNetworkAccessDisabled",
|
||||
TableConnectionString = "TableConnectionString",
|
||||
GremlinConnectionString = "GremlinConnectionString",
|
||||
}
|
||||
@@ -83,6 +84,8 @@ export function getDefaultAccountName(accountType: TestAccount): string {
|
||||
return `${accountNamePrefix}-de-test-sql-containercopy`;
|
||||
case TestAccount.SQLConnectionString:
|
||||
return `${accountNamePrefix}-de-test-sql-connstring-1`;
|
||||
case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
|
||||
return `${accountNamePrefix}-de-test-sql-connstring-nopublic-1`;
|
||||
case TestAccount.TableConnectionString:
|
||||
return `${accountNamePrefix}-de-test-table-connstring-1`;
|
||||
case TestAccount.GremlinConnectionString:
|
||||
@@ -258,6 +261,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
|
||||
break;
|
||||
|
||||
case TestAccount.SQLConnectionString:
|
||||
case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
|
||||
case TestAccount.TableConnectionString:
|
||||
case TestAccount.GremlinConnectionString:
|
||||
// Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
|
||||
|
||||
@@ -115,4 +115,32 @@ test.describe("SQL account using connection string login", () => {
|
||||
await expect(page.locator("#connectExplorer")).toHaveCount(0);
|
||||
await expect(page.locator(".errorDetails")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opens Data Explorer but loads no databases when the account rejects the client IP", async ({ page }) => {
|
||||
// An account that refuses this client's IP.
|
||||
const armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId);
|
||||
const blockedAccountName = getAccountName(TestAccount.SQLConnectionStringPublicNetworkAccessDisabled);
|
||||
const blockedAccount = await armClient.databaseAccounts.get(resourceGroupName, blockedAccountName);
|
||||
const blockedKeys = await armClient.databaseAccounts.listKeys(resourceGroupName, blockedAccountName);
|
||||
|
||||
await loginWithConnectionString(
|
||||
page,
|
||||
`AccountEndpoint=${blockedAccount.documentEndpoint!};AccountKey=${blockedKeys.primaryMasterKey};`,
|
||||
);
|
||||
|
||||
const explorer = await DataExplorer.waitForExplorer(page);
|
||||
|
||||
// Login is a client-side parse of the connection string, so nothing checks whether the account will
|
||||
// accept requests from this IP before letting the user in.
|
||||
await expect(page.locator("#connectExplorer")).toHaveCount(0);
|
||||
await expect(page.locator(".errorDetails")).toHaveCount(0);
|
||||
|
||||
// The rejection surfaces once the tree tries to read the data plane, and only in the console.
|
||||
const consoleMessages = await explorer.getNotificationConsoleMessages();
|
||||
await expect(consoleMessages).toContainText("Error while refreshing databases", { timeout: ONE_MINUTE_MS });
|
||||
|
||||
// The tree is left with the static Home node and no database or container beneath it.
|
||||
await expect(explorer.treeNode("Home").element).toBeAttached();
|
||||
await expect(explorer.frame.locator("[data-test^='TreeNode:']")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,9 +38,9 @@ async function setupIndexAdvisorTab(page: Page, customQuery?: string) {
|
||||
const queryEditor = queryTab.editor();
|
||||
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
|
||||
await queryTab.executeCTA.waitFor();
|
||||
await queryEditor.locator.click();
|
||||
|
||||
if (customQuery) {
|
||||
await queryEditor.locator.click();
|
||||
await queryEditor.setText(customQuery);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"./src/Explorer/Graph/GraphExplorerComponent/GraphData.ts",
|
||||
"./src/Explorer/LazyMonaco.ts",
|
||||
"./src/Explorer/Menus/NotificationConsole/ConsoleData.tsx",
|
||||
"./src/Explorer/Notebook/NotebookContentItem.ts",
|
||||
"./src/Explorer/OpenFullScreen.test.tsx",
|
||||
"./src/Explorer/OpenFullScreen.tsx",
|
||||
"./src/Explorer/Panes/PanelContainerComponent.test.tsx",
|
||||
@@ -107,7 +106,6 @@
|
||||
"./src/hooks/useConfig.ts",
|
||||
"./src/hooks/useDirectories.tsx",
|
||||
"./src/hooks/useGraphPhoto.tsx",
|
||||
"./src/hooks/useNotebookSnapshotStore.ts",
|
||||
"./src/hooks/useNotificationConsole.ts",
|
||||
"./src/hooks/useObservable.ts",
|
||||
"./src/hooks/useSidePanel.ts",
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
<clear />
|
||||
<add name="X-Xss-Protection" value="1; mode=block" />
|
||||
<add name="X-Content-Type-Options" value="nosniff" />
|
||||
<add name="Content-Security-Policy" value="frame-ancestors 'self' portal.azure.com *.portal.azure.com portal.azure.us portal.azure.cn portal.microsoftazure.de df.onecloud.azure-test.net *.fabric.microsoft.com *.powerbi.com *.analysis-df.windows.net dataexplorer-preview.azurewebsites.net portal.sovcloud-azure.fr portal.sovcloud-azure.de portal.sovcloud-azure.sg" />
|
||||
<add name="Content-Security-Policy" value="frame-ancestors 'self' portal.azure.com *.portal.azure.com portal.azure.us portal.azure.cn portal.microsoftazure.de df.onecloud.azure-test.net *.fabric.microsoft.com *.powerbi.com *.analysis-df.windows.net dataexplorer-preview.portal.cosmos.azure.com portal.sovcloud-azure.fr portal.sovcloud-azure.de portal.sovcloud-azure.sg" />
|
||||
</customHeaders>
|
||||
<redirectHeaders>
|
||||
<clear />
|
||||
|
||||
Reference in New Issue
Block a user