mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-24 11:21:23 +00:00
Compare commits
35 Commits
missing_pk
...
users/lang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ffcc4591 | ||
|
|
205355bf55 | ||
|
|
bb66deb3a4 | ||
|
|
fe73d0a1c6 | ||
|
|
e90e1fc581 | ||
|
|
8bcad6e0e0 | ||
|
|
9f3236c29c | ||
|
|
2f858ecf9b | ||
|
|
e220e0e74b | ||
|
|
274c85d2de | ||
|
|
d9436be61b | ||
|
|
6db2536a61 | ||
|
|
714f38a1be | ||
|
|
af4e1d10b4 | ||
|
|
6dbc412fa6 | ||
|
|
3470f56535 | ||
|
|
00ec678569 | ||
|
|
27c9ea7ab6 | ||
|
|
d5fe2d9e9f | ||
|
|
94b1e729d1 | ||
|
|
afdbefe36c | ||
|
|
2cff0fc3ff | ||
|
|
a3bfc89318 | ||
|
|
0666e11d89 | ||
|
|
9bb1d0bace | ||
|
|
af0a890516 | ||
|
|
e3c3a8b1b7 | ||
|
|
0f6c979268 | ||
|
|
32576f50d3 | ||
|
|
10f5a5fbfe | ||
|
|
8eb53674dc | ||
|
|
257256f915 | ||
|
|
41f5401016 | ||
|
|
a4c9a47d4e | ||
|
|
c43132d5c0 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -164,24 +164,24 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shardTotal: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Az CLI login"
|
||||
uses: azure/login@v1
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- name: "Az CLI login"
|
||||
uses: Azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 2.1 KiB |
8
images/golang.svg
Normal file
8
images/golang.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
<path fill="#8CC5E7" d="M21.4679537,3.20617761 C22.1814672,4.67953668 20.0131274,4.83706564 20.1243243,5.49498069 C20.3281853,6.68108108 20.1891892,8.44169884 20.0316602,10.1745174 C19.7629344,13.1119691 21.9590734,20.1451737 17.3814672,22.9714286 C16.5196911,23.5088803 14.4718147,23.8054054 12.4517375,23.8517375 C12.4517375,23.8517375 12.442471,23.8517375 12.442471,23.8517375 C12.442471,23.8517375 12.4332046,23.8517375 12.4332046,23.8517375 C10.4131274,23.8054054 8.08725869,23.5088803 7.22548263,22.9714286 C2.65714286,20.1451737 4.85328185,13.1119691 4.59382239,10.1745174 C4.42702703,8.44169884 4.28803089,6.68108108 4.5011583,5.49498069 C4.61235521,4.83706564 2.44401544,4.68880309 3.15752896,3.20617761 C3.76911197,1.93667954 5.27953668,3.05791506 5.65945946,2.65945946 C7.596139,0.648648649 9.94980695,0.111196911 11.8030888,0.0648648649 C11.988417,0.0648648649 12.8223938,0.0648648649 12.8223938,0.0648648649 C14.6664093,0.157528958 17.0200772,0.657915058 18.9660232,2.65945946 C19.3459459,3.05791506 20.8471042,1.93667954 21.4679537,3.20617761 Z M11.4324324,10.9065637 C11.3490347,10.9436293 11.2100386,11.8517375 11.6362934,11.8980695 C11.9235521,11.9258687 12.7111969,12.0185328 12.8965251,11.8980695 C13.2579151,11.6664093 13.2208494,11.1104247 13.0169884,10.9714286 C12.6741313,10.7490347 11.5250965,10.8602317 11.4324324,10.9065637 Z M9.07876448,4.10501931 C8.12432432,3.99382239 6.52123552,4.88339768 6.28030888,6.77374517 C6.02084942,8.73822394 8.33745174,10.6841699 10.56139,8.73822394 C11.7567568,7.69111969 12.1737452,4.46640927 9.07876448,4.10501931 Z M15.5281853,4.10501931 C12.4332046,4.46640927 12.8501931,7.69111969 14.0455598,8.73822394 C16.2694981,10.6841699 18.5861004,8.73822394 18.3266409,6.77374517 C18.0949807,4.88339768 16.4918919,3.99382239 15.5281853,4.10501931 Z"/>
|
||||
<path fill="#B8937F" d="M12.3127413,8.98841699 C12.8965251,8.90501931 14.2957529,9.57220077 14.2030888,10.3598456 C14.0918919,11.2772201 10.5984556,11.3976834 10.4131274,10.3042471 C10.3019305,9.63706564 10.8301158,9.21081081 12.3127413,8.98841699 Z M20.1984556,16.3737452 C19.9111969,16.3644788 19.7258687,15.984556 19.7258687,15.7528958 C19.7258687,15.3359073 19.7814672,14.8447876 20.0872587,14.6316602 C20.7173745,14.196139 21.2177606,16.3830116 20.1984556,16.3737452 Z M4.41776062,16.3737452 C3.3984556,16.3830116 3.8988417,14.196139 4.52895753,14.6316602 C4.83474903,14.8447876 4.89034749,15.3359073 4.89034749,15.7528958 C4.89034749,15.984556 4.70501931,16.3644788 4.41776062,16.3737452 Z M18.2617761,23.0918919 C18.4471042,23.3606178 18.4563707,23.5459459 18.1598456,23.6849421 C17.0293436,24.203861 16.019305,23.5088803 16.3992278,23.3142857 C17.2054054,22.9065637 17.7057915,22.2671815 18.2617761,23.0918919 Z M6.35444015,23.184556 C6.91042471,22.3598456 7.41081081,22.9992278 8.21698842,23.4069498 C8.5969112,23.6015444 7.58687259,24.2965251 6.45637066,23.7776062 C6.15984556,23.63861 6.16911197,23.4532819 6.35444015,23.184556 Z"/>
|
||||
<path fill="#000000" d="M19.7351351,3.42857143 C19.7814672,3.23397683 20.2633205,3.14131274 20.5320463,3.47490347 C20.8563707,3.87335907 20.0594595,4.42007722 20.0223938,4.1976834 C19.9297297,3.5953668 19.6795367,3.62316602 19.7351351,3.42857143 Z M4.88108108,3.42857143 C4.93667954,3.62316602 4.68648649,3.5953668 4.59382239,4.1976834 C4.55675676,4.42007722 3.75984556,3.87335907 4.08416988,3.47490347 C4.34362934,3.14131274 4.82548263,3.23397683 4.88108108,3.42857143 Z M15.7413127,7.94131274 C15.1578953,7.94131274 14.6849421,7.46835949 14.6849421,6.88494208 C14.6849421,6.30152468 15.1578953,5.82857143 15.7413127,5.82857143 C16.3247301,5.82857143 16.7976834,6.30152468 16.7976834,6.88494208 C16.7976834,7.46835949 16.3247301,7.94131274 15.7413127,7.94131274 Z M15.4633205,6.76447876 C15.6475575,6.76447876 15.7969112,6.61512511 15.7969112,6.43088803 C15.7969112,6.24665096 15.6475575,6.0972973 15.4633205,6.0972973 C15.2790834,6.0972973 15.1297297,6.24665096 15.1297297,6.43088803 C15.1297297,6.61512511 15.2790834,6.76447876 15.4633205,6.76447876 Z M11.3583012,9.43320463 C11.4694981,9.00694981 11.8586873,8.86795367 12.1737452,8.85868726 C12.9799228,8.84015444 13.2857143,9.27567568 13.3135135,9.61853282 C13.369112,10.2023166 11.1081081,10.3413127 11.3583012,9.43320463 Z M8.87490347,7.94131274 C8.29148607,7.94131274 7.81853282,7.46835949 7.81853282,6.88494208 C7.81853282,6.30152468 8.29148607,5.82857143 8.87490347,5.82857143 C9.45832088,5.82857143 9.93127413,6.30152468 9.93127413,6.88494208 C9.93127413,7.46835949 9.45832088,7.94131274 8.87490347,7.94131274 Z M9.15289575,6.76447876 C9.33713283,6.76447876 9.48648649,6.61512511 9.48648649,6.43088803 C9.48648649,6.24665096 9.33713283,6.0972973 9.15289575,6.0972973 C8.96865868,6.0972973 8.81930502,6.24665096 8.81930502,6.43088803 C8.81930502,6.61512511 8.96865868,6.76447876 9.15289575,6.76447876 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
10
images/springboot.svg
Normal file
10
images/springboot.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M38.9437824,35.879008 C89.5234256,-13.1200214 170.398168,-11.8028432 219.397197,39.0402357 C224.929346,31.6640377 229.671187,23.4975328 233.095851,15.0675923 C249.165425,64.0666217 258.912543,105.162582 255.224444,137.038295 C253.380395,163.90873 242.842969,189.725423 225.456217,210.273403 C180.145286,264.014274 99.53398,270.863601 45.7931091,225.55267 L45.7931091,225.55267 L44.765,224.638 L44.7103323,224.601984 C44.5420247,224.484832 44.376007,224.362668 44.2124952,224.235492 C43.7219599,223.853965 43.2765312,223.438607 42.8762093,222.995252 L42.732,222.831 L41.0512675,221.3377 C39.4121124,219.93271 37.7729573,218.52772 36.3188215,216.93771 L35.7825547,216.332423 C-13.2164747,165.752779 -11.6358609,84.8780374 38.9437824,35.879008 Z M57.9111486,207.375611 C53.169307,203.687512 46.3199803,204.214383 42.6318814,208.956225 C39.3888978,213.125775 39.4048731,218.924805 42.6798072,222.771269 L42.732,222.831 L44.765,224.638 L44.9644841,224.773953 C49.5691585,227.80174 55.7644273,227.175885 59.2982065,222.896387 L59.4917624,222.654878 C63.1798614,217.913037 62.3895545,211.06371 57.9111486,207.375611 Z M231.778672,28.2393744 C218.60689,55.9001168 185.940871,76.9749681 157.753257,83.5608592 C131.146257,89.8833146 107.963921,84.6146018 83.4644059,94.0982849 C27.6160498,115.436572 28.6697923,181.822354 59.2283268,196.838185 L59.2283268,196.838185 L61.0723763,197.891928 C61.0723763,197.891928 83.1456487,193.50309 104.973663,187.707242 L106.843514,187.207079 C115.561826,184.857554 124.138869,182.296538 131.146257,179.714869 C167.500376,166.279651 207.542593,133.08676 220.714375,94.6251562 C213.865049,134.667374 179.35498,173.392413 144.84491,191.042601 C126.404416,200.526284 112.178891,202.633769 81.883792,213.171195 C78.195693,214.488373 75.297901,215.805551 75.297901,215.805551 C75.6675607,215.754564 76.0372203,215.70481 76.4060145,215.65629 L77.1421925,215.560893 L77.1421925,215.560893 L77.8745239,215.468787 C84.5652297,214.639554 90.5771682,214.224938 90.5771682,214.224938 C133.517178,212.117452 200.956702,226.342977 232.305544,184.45671 C264.444692,141.780136 246.531068,72.7599979 231.778672,28.2393744 Z" fill="#6DB33F">
|
||||
|
||||
</path>
|
||||
<path d="M57.9111486,207.375611 C62.3895545,211.06371 63.1798614,217.913037 59.4917624,222.654878 C55.8036635,227.39672 48.9543368,227.923591 44.2124952,224.235492 C39.4706537,220.547393 38.9437824,213.698066 42.6318814,208.956225 C46.3199803,204.214383 53.169307,203.687512 57.9111486,207.375611 Z M231.778672,28.2393744 C246.531068,72.7599979 264.444692,141.780136 232.305544,184.45671 C200.956702,226.342977 133.517178,212.117452 90.5771682,214.224938 C90.5771682,214.224938 84.5652297,214.639554 77.8745239,215.468787 L77.1421925,215.560893 C76.5300999,215.63902 75.9140004,215.720572 75.297901,215.805551 C75.297901,215.805551 78.195693,214.488373 81.883792,213.171195 C112.178891,202.633769 126.404416,200.526284 144.84491,191.042601 C179.35498,173.392413 213.865049,134.667374 220.714375,94.6251562 C207.542593,133.08676 167.500376,166.279651 131.146257,179.714869 C106.119871,188.935116 61.0723763,197.891928 61.0723763,197.891928 L59.2283268,196.838185 C28.6697923,181.822354 27.6160498,115.436572 83.4644059,94.0982849 C107.963921,84.6146018 131.146257,89.8833146 157.753257,83.5608592 C185.940871,76.9749681 218.60689,55.9001168 231.778672,28.2393744 Z" fill="#FFFFFF">
|
||||
|
||||
</path>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
1322
less/quickstart.less
1322
less/quickstart.less
File diff suppressed because it is too large
Load Diff
15
package-lock.json
generated
15
package-lock.json
generated
@@ -51,6 +51,8 @@
|
||||
"@types/mkdirp": "1.0.1",
|
||||
"@types/node-fetch": "2.5.7",
|
||||
"@xmldom/xmldom": "0.7.13",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"allotment": "1.20.2",
|
||||
"applicationinsights": "1.8.0",
|
||||
"bootstrap": "3.4.1",
|
||||
@@ -13240,6 +13242,19 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"license": "BSD-3-Clause"
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"@types/mkdirp": "1.0.1",
|
||||
"@types/node-fetch": "2.5.7",
|
||||
"@xmldom/xmldom": "0.7.13",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"allotment": "1.20.2",
|
||||
"applicationinsights": "1.8.0",
|
||||
"bootstrap": "3.4.1",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -29,24 +28,60 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: {
|
||||
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
launchOptions: {
|
||||
firefoxUserPrefs: {
|
||||
"security.fileuri.strict_origin_policy": false,
|
||||
"network.http.referer.XOriginPolicy": 0,
|
||||
"network.http.referer.trimmingPolicy": 0,
|
||||
"privacy.file_unique_origin": false,
|
||||
"security.csp.enable": false,
|
||||
"network.cors_preflight.allow_client_cert": true,
|
||||
"dom.security.https_first": false,
|
||||
"network.http.cross-origin-embedder-policy": false,
|
||||
"network.http.cross-origin-opener-policy": false,
|
||||
"browser.tabs.remote.useCrossOriginPolicy": false,
|
||||
"browser.tabs.remote.useCORP": false,
|
||||
},
|
||||
args: ["--disable-web-security"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
/* Test against branded browsers. */
|
||||
{
|
||||
name: "Google Chrome",
|
||||
use: { ...devices["Desktop Chrome"], channel: "chrome" }, // or 'chrome-beta'
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: "chrome",
|
||||
launchOptions: {
|
||||
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Microsoft Edge",
|
||||
use: { ...devices["Desktop Edge"], channel: "msedge" }, // or 'msedge-dev'
|
||||
use: {
|
||||
...devices["Desktop Edge"],
|
||||
channel: "msedge",
|
||||
launchOptions: {
|
||||
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ export class Areas {
|
||||
public static ShareDialog: string = "Share Access Dialog";
|
||||
public static Notebook: string = "Notebook";
|
||||
public static Copilot: string = "Copilot";
|
||||
public static CloudShell: string = "Cloud Shell";
|
||||
}
|
||||
|
||||
export class HttpHeaders {
|
||||
@@ -530,6 +531,9 @@ export class ariaLabelForLearnMoreLink {
|
||||
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
|
||||
}
|
||||
|
||||
export class GlobalSecondaryIndexLabels {
|
||||
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
|
||||
}
|
||||
export class FeedbackLabels {
|
||||
public static readonly provideFeedback: string = "Provide feedback";
|
||||
}
|
||||
|
||||
@@ -125,7 +125,11 @@ export const endpoint = () => {
|
||||
const location = _global.parent ? _global.parent.location : _global.location;
|
||||
return configContext.EMULATOR_ENDPOINT || location.origin;
|
||||
}
|
||||
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint;
|
||||
return (
|
||||
userContext.selectedRegionalEndpoint ||
|
||||
userContext.endpoint ||
|
||||
userContext?.databaseAccount?.properties?.documentEndpoint
|
||||
);
|
||||
};
|
||||
|
||||
export async function getTokenFromAuthService(
|
||||
@@ -203,6 +207,7 @@ export function client(): Cosmos.CosmosClient {
|
||||
userAgentSuffix: "Azure Portal",
|
||||
defaultHeaders: _defaultHeaders,
|
||||
connectionPolicy: {
|
||||
enableEndpointDiscovery: !userContext.selectedRegionalEndpoint,
|
||||
retryOptions: {
|
||||
maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts),
|
||||
fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TagNames, WorkloadType } from "Common/Constants";
|
||||
import { Tags } from "Contracts/DataModels";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { userContext } from "../UserContext";
|
||||
|
||||
function isVirtualNetworkFilterEnabled() {
|
||||
@@ -26,3 +27,9 @@ export function getWorkloadType(): WorkloadType {
|
||||
}
|
||||
return workloadType;
|
||||
}
|
||||
|
||||
export function isGlobalSecondaryIndexEnabled(): boolean {
|
||||
return (
|
||||
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { QueryOperationOptions } from "@azure/cosmos";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import { QueryResults } from "../Contracts/ViewModels";
|
||||
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
interface QueryResponse {
|
||||
// [Todo] remove any
|
||||
@@ -21,7 +24,9 @@ export function nextPage(
|
||||
firstItemIndex: number,
|
||||
queryOperationOptions?: QueryOperationOptions,
|
||||
): Promise<QueryResults> {
|
||||
TelemetryProcessor.traceStart(Action.ExecuteQuery);
|
||||
return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
|
||||
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
|
||||
const documents = response.resources;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any
|
||||
|
||||
74
src/Common/dataAccess/createMaterializedView.ts
Normal file
74
src/Common/dataAccess/createMaterializedView.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { constructRpOptions } from "Common/dataAccess/createCollection";
|
||||
import { handleError } from "Common/ErrorHandlingUtils";
|
||||
import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels";
|
||||
import { userContext } from "UserContext";
|
||||
import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources";
|
||||
import {
|
||||
CreateUpdateOptions,
|
||||
SqlContainerResource,
|
||||
SqlDatabaseCreateUpdateParameters,
|
||||
} from "Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
|
||||
export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise<Collection> => {
|
||||
const clearMessage = logConsoleProgress(
|
||||
`Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`,
|
||||
);
|
||||
|
||||
const options: CreateUpdateOptions = constructRpOptions(params);
|
||||
|
||||
const resource: SqlContainerResource = {
|
||||
id: params.materializedViewId,
|
||||
};
|
||||
if (params.materializedViewDefinition) {
|
||||
resource.materializedViewDefinition = params.materializedViewDefinition;
|
||||
}
|
||||
if (params.analyticalStorageTtl) {
|
||||
resource.analyticalStorageTtl = params.analyticalStorageTtl;
|
||||
}
|
||||
if (params.indexingPolicy) {
|
||||
resource.indexingPolicy = params.indexingPolicy;
|
||||
}
|
||||
if (params.partitionKey) {
|
||||
resource.partitionKey = params.partitionKey;
|
||||
}
|
||||
if (params.uniqueKeyPolicy) {
|
||||
resource.uniqueKeyPolicy = params.uniqueKeyPolicy;
|
||||
}
|
||||
if (params.vectorEmbeddingPolicy) {
|
||||
resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy;
|
||||
}
|
||||
if (params.fullTextPolicy) {
|
||||
resource.fullTextPolicy = params.fullTextPolicy;
|
||||
}
|
||||
|
||||
const rpPayload: SqlDatabaseCreateUpdateParameters = {
|
||||
properties: {
|
||||
resource,
|
||||
options,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const createResponse = await createUpdateSqlContainer(
|
||||
userContext.subscriptionId,
|
||||
userContext.resourceGroup,
|
||||
userContext.databaseAccount.name,
|
||||
params.databaseId,
|
||||
params.materializedViewId,
|
||||
rpPayload,
|
||||
);
|
||||
logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`);
|
||||
|
||||
return createResponse && (createResponse.properties.resource as Collection);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
"CreateGlobalSecondaryIndex",
|
||||
`Error while creating global secondary index ${params.materializedViewId}`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels";
|
||||
import { userContext } from "../../UserContext";
|
||||
@@ -13,6 +14,11 @@ import { readOfferWithSDK } from "./readOfferWithSDK";
|
||||
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
|
||||
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
|
||||
|
||||
if (isFabric()) {
|
||||
// Not exposing offers in Fabric
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
|
||||
@@ -126,5 +126,12 @@ async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Co
|
||||
throw new Error(`Unsupported default experience type: ${apiType}`);
|
||||
}
|
||||
|
||||
return rpResponse?.value?.map((collection) => collection.properties?.resource as DataModels.Collection);
|
||||
// TO DO: Remove when we get RP API Spec with materializedViews
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
return rpResponse?.value?.map((collection: any) => {
|
||||
const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection;
|
||||
collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews;
|
||||
collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition;
|
||||
return collectionDataModel;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ export type DataExploreMessageV3 =
|
||||
| {
|
||||
type: FabricMessageTypes.GetAllResourceTokens;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.OpenSettings;
|
||||
params: [{ settingsId?: "About" | "Connection" }];
|
||||
};
|
||||
|
||||
export type GetCosmosTokenMessageOptions = {
|
||||
export interface GetCosmosTokenMessageOptions {
|
||||
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges";
|
||||
resourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface DatabaseAccountExtendedProperties {
|
||||
writeLocations?: DatabaseAccountResponseLocation[];
|
||||
enableFreeTier?: boolean;
|
||||
enableAnalyticalStorage?: boolean;
|
||||
enableMaterializedViews?: boolean;
|
||||
isVirtualNetworkFilterEnabled?: boolean;
|
||||
ipRules?: IpRule[];
|
||||
privateEndpointConnections?: unknown[];
|
||||
@@ -164,6 +165,8 @@ export interface Collection extends Resource {
|
||||
schema?: ISchema;
|
||||
requestSchema?: () => void;
|
||||
computedProperties?: ComputedProperties;
|
||||
materializedViews?: MaterializedView[];
|
||||
materializedViewDefinition?: MaterializedViewDefinition;
|
||||
}
|
||||
|
||||
export interface CollectionsWithPagination {
|
||||
@@ -223,6 +226,17 @@ export interface ComputedProperty {
|
||||
|
||||
export type ComputedProperties = ComputedProperty[];
|
||||
|
||||
export interface MaterializedView {
|
||||
id: string;
|
||||
_rid: string;
|
||||
}
|
||||
|
||||
export interface MaterializedViewDefinition {
|
||||
definition: string;
|
||||
sourceCollectionId: string;
|
||||
sourceCollectionRid?: string;
|
||||
}
|
||||
|
||||
export interface PartitionKey {
|
||||
paths: string[];
|
||||
kind: "Hash" | "Range" | "MultiHash";
|
||||
@@ -345,9 +359,7 @@ export interface CreateDatabaseParams {
|
||||
offerThroughput?: number;
|
||||
}
|
||||
|
||||
export interface CreateCollectionParams {
|
||||
createNewDatabase: boolean;
|
||||
collectionId: string;
|
||||
export interface CreateCollectionParamsBase {
|
||||
databaseId: string;
|
||||
databaseLevelThroughput: boolean;
|
||||
offerThroughput?: number;
|
||||
@@ -361,6 +373,16 @@ export interface CreateCollectionParams {
|
||||
fullTextPolicy?: FullTextPolicy;
|
||||
}
|
||||
|
||||
export interface CreateCollectionParams extends CreateCollectionParamsBase {
|
||||
createNewDatabase: boolean;
|
||||
collectionId: string;
|
||||
}
|
||||
|
||||
export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase {
|
||||
materializedViewId: string;
|
||||
materializedViewDefinition: MaterializedViewDefinition;
|
||||
}
|
||||
|
||||
export interface VectorEmbeddingPolicy {
|
||||
vectorEmbeddings: VectorEmbedding[];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum FabricMessageTypes {
|
||||
GetAllResourceTokens = "GetAllResourceTokens",
|
||||
GetAccessToken = "GetAccessToken",
|
||||
Ready = "Ready",
|
||||
OpenSettings = "OpenSettings",
|
||||
}
|
||||
|
||||
export interface AuthorizationToken {
|
||||
|
||||
@@ -81,6 +81,13 @@ export type FabricMessageV3 =
|
||||
error: string | undefined;
|
||||
data: { accessToken: string };
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "refreshResourceTree";
|
||||
message: {
|
||||
id: string;
|
||||
error: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export enum CosmosDbArtifactType {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
JSONObject,
|
||||
QueryMetrics,
|
||||
Resource,
|
||||
StoredProcedureDefinition,
|
||||
@@ -143,6 +144,8 @@ export interface Collection extends CollectionBase {
|
||||
geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
|
||||
documentIds: ko.ObservableArray<DocumentId>;
|
||||
computedProperties: ko.Observable<DataModels.ComputedProperties>;
|
||||
materializedViews: ko.Observable<DataModels.MaterializedView[]>;
|
||||
materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
|
||||
|
||||
cassandraKeys: CassandraTableKeys;
|
||||
cassandraSchema: CassandraTableKey[];
|
||||
@@ -204,6 +207,12 @@ export interface Collection extends CollectionBase {
|
||||
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
|
||||
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
|
||||
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
|
||||
bulkInsertDocuments(documents: JSONObject[]): Promise<{
|
||||
numSucceeded: number;
|
||||
numFailed: number;
|
||||
numThrottled: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { GlobalSecondaryIndexLabels } from "Common/Constants";
|
||||
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
import {
|
||||
AddGlobalSecondaryIndexPanel,
|
||||
AddGlobalSecondaryIndexPanelProps,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
@@ -97,17 +103,23 @@ export const createCollectionContextMenuButton = (
|
||||
iconSrc: HostedTerminalIcon,
|
||||
onClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
|
||||
if (useNotebook.getState().isShellEnabled) {
|
||||
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
|
||||
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
|
||||
} else {
|
||||
selectedCollection && selectedCollection.onNewMongoShellClick();
|
||||
}
|
||||
},
|
||||
label: useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell",
|
||||
label:
|
||||
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
|
||||
? "Open Mongo Shell"
|
||||
: "New Shell",
|
||||
});
|
||||
}
|
||||
|
||||
if (useNotebook.getState().isShellEnabled && userContext.apiType === "Cassandra") {
|
||||
if (
|
||||
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
|
||||
userContext.apiType === "Cassandra"
|
||||
) {
|
||||
items.push({
|
||||
iconSrc: HostedTerminalIcon,
|
||||
onClick: () => {
|
||||
@@ -164,6 +176,24 @@ export const createCollectionContextMenuButton = (
|
||||
});
|
||||
}
|
||||
|
||||
if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) {
|
||||
items.push({
|
||||
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
|
||||
onClick: () => {
|
||||
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
|
||||
explorer: container,
|
||||
sourceContainer: selectedCollection,
|
||||
};
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
|
||||
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
|
||||
@@ -214,8 +214,10 @@ export const Dialog: FC = () => {
|
||||
{contentHtml}
|
||||
{progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />}
|
||||
<DialogFooter>
|
||||
<PrimaryButton {...primaryButtonProps} />
|
||||
{secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />}
|
||||
<PrimaryButton {...primaryButtonProps} data-test={`DialogButton:${primaryButtonText}`} />
|
||||
{secondaryButtonProps && (
|
||||
<DefaultButton {...secondaryButtonProps} data-test={`DialogButton:${secondaryButtonText}`} />
|
||||
)}
|
||||
</DialogFooter>
|
||||
</FluentDialog>
|
||||
) : (
|
||||
|
||||
@@ -193,6 +193,7 @@ export const InputDataList: FC<InputDataListProps> = ({
|
||||
<>
|
||||
<Input
|
||||
id="filterInput"
|
||||
data-test={"DocumentsTab/FilterInput"}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
size="small"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ThroughputBucketsComponentProps,
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
||||
import * as React from "react";
|
||||
@@ -44,6 +45,10 @@ import {
|
||||
ConflictResolutionComponent,
|
||||
ConflictResolutionComponentProps,
|
||||
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
||||
import {
|
||||
GlobalSecondaryIndexComponent,
|
||||
GlobalSecondaryIndexComponentProps,
|
||||
} from "./SettingsSubComponents/GlobalSecondaryIndexComponent";
|
||||
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
|
||||
import {
|
||||
MongoIndexingPolicyComponent,
|
||||
@@ -162,6 +167,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private shouldShowComputedPropertiesEditor: boolean;
|
||||
private shouldShowIndexingPolicyEditor: boolean;
|
||||
private shouldShowPartitionKeyEditor: boolean;
|
||||
private isGlobalSecondaryIndex: boolean;
|
||||
private isVectorSearchEnabled: boolean;
|
||||
private isFullTextSearchEnabled: boolean;
|
||||
private totalThroughputUsed: number;
|
||||
@@ -179,6 +185,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL";
|
||||
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
|
||||
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
|
||||
this.isGlobalSecondaryIndex =
|
||||
!!this.collection?.materializedViewDefinition() || !!this.collection?.materializedViews();
|
||||
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
|
||||
this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
|
||||
|
||||
@@ -1141,6 +1149,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
collection: this.collection,
|
||||
database: this.database,
|
||||
isFixedContainer: this.isFixedContainer,
|
||||
isGlobalSecondaryIndex: this.isGlobalSecondaryIndex,
|
||||
onThroughputChange: this.onThroughputChange,
|
||||
throughput: this.state.throughput,
|
||||
throughputBaseline: this.state.throughputBaseline,
|
||||
@@ -1270,6 +1279,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId),
|
||||
collection: this.collection,
|
||||
explorer: this.props.settingsTab.getContainer(),
|
||||
isReadOnly: isFabricNative(),
|
||||
};
|
||||
|
||||
const globalSecondaryIndexComponentProps: GlobalSecondaryIndexComponentProps = {
|
||||
collection: this.collection,
|
||||
explorer: this.props.settingsTab.getContainer(),
|
||||
};
|
||||
|
||||
const tabs: SettingsV2TabInfo[] = [];
|
||||
@@ -1335,6 +1350,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isGlobalSecondaryIndex) {
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.GlobalSecondaryIndexTab,
|
||||
content: <GlobalSecondaryIndexComponent {...globalSecondaryIndexComponentProps} />,
|
||||
});
|
||||
}
|
||||
|
||||
const pivotProps: IPivotProps = {
|
||||
onLinkClick: this.onPivotChange,
|
||||
selectedKey: SettingsV2TabTypes[this.state.selectedTab],
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { collection, container } from "../TestUtils";
|
||||
import { GlobalSecondaryIndexComponent } from "./GlobalSecondaryIndexComponent";
|
||||
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
|
||||
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
|
||||
|
||||
describe("GlobalSecondaryIndexComponent", () => {
|
||||
let testCollection: typeof collection;
|
||||
let testExplorer: typeof container;
|
||||
|
||||
beforeEach(() => {
|
||||
testCollection = { ...collection };
|
||||
});
|
||||
|
||||
it("renders only the source component when materializedViewDefinition is missing", () => {
|
||||
testCollection.materializedViews([
|
||||
{ id: "view1", _rid: "rid1" },
|
||||
{ id: "view2", _rid: "rid2" },
|
||||
]);
|
||||
testCollection.materializedViewDefinition(null);
|
||||
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
|
||||
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(true);
|
||||
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders only the target component when materializedViews is missing", () => {
|
||||
testCollection.materializedViews(null);
|
||||
testCollection.materializedViewDefinition({
|
||||
definition: "SELECT * FROM c WHERE c.id = 1",
|
||||
sourceCollectionId: "source1",
|
||||
sourceCollectionRid: "rid123",
|
||||
});
|
||||
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
|
||||
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false);
|
||||
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders neither component when both are missing", () => {
|
||||
testCollection.materializedViews(null);
|
||||
testCollection.materializedViewDefinition(null);
|
||||
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
|
||||
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false);
|
||||
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FontIcon, Link, Stack, Text } from "@fluentui/react";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
|
||||
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
|
||||
|
||||
export interface GlobalSecondaryIndexComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
export const GlobalSecondaryIndexComponent: React.FC<GlobalSecondaryIndexComponentProps> = ({
|
||||
collection,
|
||||
explorer,
|
||||
}) => {
|
||||
const isTargetContainer = !!collection?.materializedViewDefinition();
|
||||
const isSourceContainer = !!collection?.materializedViews();
|
||||
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 8 }} styles={{ root: { maxWidth: 600 } }}>
|
||||
<Stack horizontal verticalAlign="center" wrap tokens={{ childrenGap: 8 }}>
|
||||
{isSourceContainer && (
|
||||
<Text styles={{ root: { fontWeight: 600 } }}>This container has the following indexes defined for it.</Text>
|
||||
)}
|
||||
<Text>
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
|
||||
>
|
||||
Learn more
|
||||
<FontIcon iconName="NavigateExternalInline" style={{ marginLeft: "4px" }} />
|
||||
</Link>{" "}
|
||||
about how to define global secondary indexes and how to use them.
|
||||
</Text>
|
||||
</Stack>
|
||||
{isSourceContainer && <GlobalSecondaryIndexSourceComponent collection={collection} explorer={explorer} />}
|
||||
{isTargetContainer && <GlobalSecondaryIndexTargetComponent collection={collection} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PrimaryButton } from "@fluentui/react";
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { collection, container } from "../TestUtils";
|
||||
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
|
||||
|
||||
describe("GlobalSecondaryIndexSourceComponent", () => {
|
||||
let testCollection: typeof collection;
|
||||
let testExplorer: typeof container;
|
||||
|
||||
beforeEach(() => {
|
||||
testCollection = { ...collection };
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const wrapper = shallow(
|
||||
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
|
||||
);
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the PrimaryButton", () => {
|
||||
const wrapper = shallow(
|
||||
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
|
||||
);
|
||||
expect(wrapper.find(PrimaryButton).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when new global secondary indexes are provided", () => {
|
||||
const wrapper = shallow(
|
||||
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
|
||||
);
|
||||
|
||||
// Simulating an update by modifying the observable directly
|
||||
testCollection.materializedViews([{ id: "view3", _rid: "rid3" }]);
|
||||
|
||||
wrapper.setProps({ collection: testCollection });
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(PrimaryButton).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { PrimaryButton } from "@fluentui/react";
|
||||
import { GlobalSecondaryIndexLabels } from "Common/Constants";
|
||||
import { MaterializedView } from "Contracts/DataModels";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { loadMonaco } from "Explorer/LazyMonaco";
|
||||
import { AddGlobalSecondaryIndexPanel } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import * as monaco from "monaco-editor";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||
|
||||
export interface GlobalSecondaryIndexSourceComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
export const GlobalSecondaryIndexSourceComponent: React.FC<GlobalSecondaryIndexSourceComponentProps> = ({
|
||||
collection,
|
||||
explorer,
|
||||
}) => {
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(null);
|
||||
|
||||
const globalSecondaryIndexes: MaterializedView[] = collection?.materializedViews() ?? [];
|
||||
|
||||
// Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedView[] with collection id.
|
||||
const getViewDetails = (viewId: string): { definition: string; partitionKey: string[] } => {
|
||||
let definition = "";
|
||||
let partitionKey: string[] = [];
|
||||
|
||||
useDatabases.getState().databases.find((database) => {
|
||||
const collection = database.collections().find((collection) => collection.id() === viewId);
|
||||
if (collection) {
|
||||
const globalSecondaryIndexDefinition = collection.materializedViewDefinition();
|
||||
globalSecondaryIndexDefinition && (definition = globalSecondaryIndexDefinition.definition);
|
||||
collection.partitionKey?.paths && (partitionKey = collection.partitionKey.paths);
|
||||
}
|
||||
});
|
||||
|
||||
return { definition, partitionKey };
|
||||
};
|
||||
|
||||
//JSON value for the editor using the fetched id and definitions.
|
||||
const jsonValue = JSON.stringify(
|
||||
globalSecondaryIndexes.map((view) => {
|
||||
const { definition, partitionKey } = getViewDetails(view.id);
|
||||
return {
|
||||
name: view.id,
|
||||
partitionKey: partitionKey.join(", "),
|
||||
definition,
|
||||
};
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// Initialize Monaco editor with the computed JSON value.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const initMonaco = async () => {
|
||||
const monacoInstance = await loadMonaco();
|
||||
if (disposed || !editorContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorRef.current = monacoInstance.editor.create(editorContainerRef.current, {
|
||||
value: jsonValue,
|
||||
language: "json",
|
||||
ariaLabel: "Global Secondary Index JSON",
|
||||
readOnly: true,
|
||||
});
|
||||
};
|
||||
|
||||
initMonaco();
|
||||
return () => {
|
||||
disposed = true;
|
||||
editorRef.current?.dispose();
|
||||
};
|
||||
}, [jsonValue]);
|
||||
|
||||
// Update the editor when the jsonValue changes.
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setValue(jsonValue);
|
||||
}
|
||||
}, [jsonValue]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={editorContainerRef}
|
||||
style={{
|
||||
height: 250,
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
text="Add index"
|
||||
styles={{ root: { width: "fit-content", marginTop: 12 } }}
|
||||
onClick={() =>
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
|
||||
<AddGlobalSecondaryIndexPanel explorer={explorer} sourceContainer={collection} />,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Text } from "@fluentui/react";
|
||||
import { Collection } from "Contracts/ViewModels";
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { collection } from "../TestUtils";
|
||||
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
|
||||
|
||||
describe("GlobalSecondaryIndexTargetComponent", () => {
|
||||
let testCollection: Collection;
|
||||
|
||||
beforeEach(() => {
|
||||
testCollection = {
|
||||
...collection,
|
||||
materializedViewDefinition: collection.materializedViewDefinition,
|
||||
};
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("displays the source container ID", () => {
|
||||
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
|
||||
expect(wrapper.find(Text).at(2).dive().text()).toBe("source1");
|
||||
});
|
||||
|
||||
it("displays the global secondary index definition", () => {
|
||||
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
|
||||
expect(wrapper.find(Text).at(4).dive().text()).toBe("SELECT * FROM c WHERE c.id = 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Stack, Text } from "@fluentui/react";
|
||||
import * as React from "react";
|
||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||
|
||||
export interface GlobalSecondaryIndexTargetComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
}
|
||||
|
||||
export const GlobalSecondaryIndexTargetComponent: React.FC<GlobalSecondaryIndexTargetComponentProps> = ({
|
||||
collection,
|
||||
}) => {
|
||||
const globalSecondaryIndexDefinition = collection?.materializedViewDefinition();
|
||||
|
||||
const textHeadingStyle = {
|
||||
root: { fontWeight: "600", fontSize: 16 },
|
||||
};
|
||||
|
||||
const valueBoxStyle = {
|
||||
root: {
|
||||
backgroundColor: "#f3f3f3",
|
||||
padding: "5px 10px",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 15 }} styles={{ root: { maxWidth: 600 } }}>
|
||||
<Text styles={textHeadingStyle}>Global Secondary Index Settings</Text>
|
||||
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Text styles={{ root: { fontWeight: "600" } }}>Source container</Text>
|
||||
<Stack styles={valueBoxStyle}>
|
||||
<Text>{globalSecondaryIndexDefinition?.sourceCollectionId}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Text styles={{ root: { fontWeight: "600" } }}>Global secondary index definition</Text>
|
||||
<Stack styles={valueBoxStyle}>
|
||||
<Text>{globalSecondaryIndexDefinition?.definition}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { shallow } from "enzyme";
|
||||
import {
|
||||
PartitionKeyComponent,
|
||||
PartitionKeyComponentProps,
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
|
||||
describe("PartitionKeyComponent", () => {
|
||||
// Create a test setup function to get fresh instances for each test
|
||||
const setupTest = () => {
|
||||
// Create an instance of the mocked Explorer
|
||||
const explorer = new Explorer();
|
||||
// Create minimal mock objects for database and collection
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockCollection = {} as any as import("../../../../Contracts/ViewModels").Collection;
|
||||
|
||||
// Create props with the mocked Explorer instance
|
||||
const props: PartitionKeyComponentProps = {
|
||||
database: mockDatabase,
|
||||
collection: mockCollection,
|
||||
explorer,
|
||||
};
|
||||
|
||||
return { explorer, props };
|
||||
};
|
||||
|
||||
it("renders default component and matches snapshot", () => {
|
||||
const { props } = setupTest();
|
||||
const wrapper = shallow(<PartitionKeyComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders read-only component and matches snapshot", () => {
|
||||
const { props } = setupTest();
|
||||
const wrapper = shallow(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -29,16 +29,26 @@ export interface PartitionKeyComponentProps {
|
||||
database: ViewModels.Database;
|
||||
collection: ViewModels.Collection;
|
||||
explorer: Explorer;
|
||||
isReadOnly?: boolean; // true: cannot change partition key
|
||||
}
|
||||
|
||||
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ database, collection, explorer }) => {
|
||||
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
||||
database,
|
||||
collection,
|
||||
explorer,
|
||||
isReadOnly,
|
||||
}) => {
|
||||
const { dataTransferJobs } = useDataTransferJobs();
|
||||
const [portalDataTransferJob, setPortalDataTransferJob] = React.useState<DataTransferJobGetResults>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isReadOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadDataTransferJobs = refreshDataTransferOperations;
|
||||
loadDataTransferJobs();
|
||||
}, []);
|
||||
}, [isReadOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const currentJob = findPortalDataTransferJob();
|
||||
@@ -151,7 +161,7 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ da
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 20 }} styles={{ root: { maxWidth: 600 } }}>
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Text styles={textHeadingStyle}>Change {partitionKeyName.toLowerCase()}</Text>
|
||||
{!isReadOnly && <Text styles={textHeadingStyle}>Change {partitionKeyName.toLowerCase()}</Text>}
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Text styles={textSubHeadingStyle}>Current {partitionKeyName.toLowerCase()}</Text>
|
||||
@@ -163,56 +173,61 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ da
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<MessageBar messageBarType={MessageBarType.warning}>
|
||||
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the
|
||||
source container for the entire duration of the partition key change process.
|
||||
<Link
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
|
||||
target="_blank"
|
||||
underline
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</MessageBar>
|
||||
<Text>
|
||||
To change the partition key, a new destination container must be created or an existing destination container
|
||||
selected. Data will then be copied to the destination container.
|
||||
</Text>
|
||||
{configContext.platform !== Platform.Emulator && (
|
||||
<PrimaryButton
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
text="Change"
|
||||
onClick={startPartitionkeyChangeWorkflow}
|
||||
disabled={isCurrentJobInProgress(portalDataTransferJob)}
|
||||
/>
|
||||
)}
|
||||
{portalDataTransferJob && (
|
||||
<Stack>
|
||||
<Text styles={textHeadingStyle}>{partitionKeyName} change job</Text>
|
||||
<Stack
|
||||
horizontal
|
||||
tokens={{ childrenGap: 20 }}
|
||||
styles={{
|
||||
root: {
|
||||
alignItems: "center",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ProgressIndicator
|
||||
label={portalDataTransferJob?.properties?.jobName}
|
||||
description={getProgressDescription()}
|
||||
percentComplete={getPercentageComplete()}
|
||||
styles={{
|
||||
root: {
|
||||
width: "85%",
|
||||
},
|
||||
}}
|
||||
></ProgressIndicator>
|
||||
{isCurrentJobInProgress(portalDataTransferJob) && (
|
||||
<DefaultButton text="Cancel" onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)} />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<MessageBar messageBarType={MessageBarType.warning}>
|
||||
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to
|
||||
the source container for the entire duration of the partition key change process.
|
||||
<Link
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
|
||||
target="_blank"
|
||||
underline
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</MessageBar>
|
||||
<Text>
|
||||
To change the partition key, a new destination container must be created or an existing destination
|
||||
container selected. Data will then be copied to the destination container.
|
||||
</Text>
|
||||
{configContext.platform !== Platform.Emulator && (
|
||||
<PrimaryButton
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
text="Change"
|
||||
onClick={startPartitionkeyChangeWorkflow}
|
||||
disabled={isCurrentJobInProgress(portalDataTransferJob)}
|
||||
/>
|
||||
)}
|
||||
{portalDataTransferJob && (
|
||||
<Stack>
|
||||
<Text styles={textHeadingStyle}>{partitionKeyName} change job</Text>
|
||||
<Stack
|
||||
horizontal
|
||||
tokens={{ childrenGap: 20 }}
|
||||
styles={{
|
||||
root: {
|
||||
alignItems: "center",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ProgressIndicator
|
||||
label={portalDataTransferJob?.properties?.jobName}
|
||||
description={getProgressDescription()}
|
||||
percentComplete={getPercentageComplete()}
|
||||
styles={{
|
||||
root: {
|
||||
width: "85%",
|
||||
},
|
||||
}}
|
||||
></ProgressIndicator>
|
||||
{isCurrentJobInProgress(portalDataTransferJob) && (
|
||||
<DefaultButton text="Cancel" onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)} />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("ScaleComponent", () => {
|
||||
collection: collection,
|
||||
database: undefined,
|
||||
isFixedContainer: false,
|
||||
isGlobalSecondaryIndex: false,
|
||||
onThroughputChange: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ScaleComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
database: ViewModels.Database;
|
||||
isFixedContainer: boolean;
|
||||
isGlobalSecondaryIndex: boolean;
|
||||
onThroughputChange: (newThroughput: number) => void;
|
||||
throughput: number;
|
||||
throughputBaseline: number;
|
||||
@@ -143,6 +144,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
throughputError={this.props.throughputError}
|
||||
instantMaximumThroughput={this.offer?.instantMaximumThroughput}
|
||||
softAllowedMaximumThroughput={this.offer?.softAllowedMaximumThroughput}
|
||||
isGlobalSecondaryIndex={this.props.isGlobalSecondaryIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("ThroughputInputAutoPilotV3Component", () => {
|
||||
},
|
||||
instantMaximumThroughput: 5000,
|
||||
softAllowedMaximumThroughput: 1000000,
|
||||
isGlobalSecondaryIndex: false,
|
||||
};
|
||||
|
||||
it("throughput input visible", () => {
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface ThroughputInputAutoPilotV3Props {
|
||||
throughputError?: string;
|
||||
instantMaximumThroughput: number;
|
||||
softAllowedMaximumThroughput: number;
|
||||
isGlobalSecondaryIndex: boolean;
|
||||
}
|
||||
|
||||
interface ThroughputInputAutoPilotV3State {
|
||||
@@ -375,22 +376,26 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
|
||||
toolTipElement={getToolTipContainer(this.props.infoBubbleText)}
|
||||
/>
|
||||
</Label>
|
||||
{this.overrideWithProvisionedThroughputSettings() && (
|
||||
<MessageBar
|
||||
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
|
||||
styles={messageBarStyles}
|
||||
>
|
||||
{manualToAutoscaleDisclaimerElement}
|
||||
</MessageBar>
|
||||
{!this.props.isGlobalSecondaryIndex && (
|
||||
<>
|
||||
{this.overrideWithProvisionedThroughputSettings() && (
|
||||
<MessageBar
|
||||
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
|
||||
styles={messageBarStyles}
|
||||
>
|
||||
{manualToAutoscaleDisclaimerElement}
|
||||
</MessageBar>
|
||||
)}
|
||||
<ChoiceGroup
|
||||
selectedKey={this.props.isAutoPilotSelected.toString()}
|
||||
options={this.options}
|
||||
onChange={this.onChoiceGroupChange}
|
||||
required={this.props.showAsMandatory}
|
||||
ariaLabelledBy={labelId}
|
||||
styles={getChoiceGroupStyles(this.props.wasAutopilotOriginallySet, this.props.isAutoPilotSelected, true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ChoiceGroup
|
||||
selectedKey={this.props.isAutoPilotSelected.toString()}
|
||||
options={this.options}
|
||||
onChange={this.onChoiceGroupChange}
|
||||
required={this.props.showAsMandatory}
|
||||
ariaLabelledBy={labelId}
|
||||
styles={getChoiceGroupStyles(this.props.wasAutopilotOriginallySet, this.props.isAutoPilotSelected, true)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"maxWidth": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"fontSize": 16,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Change
|
||||
partition key
|
||||
</Text>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Current
|
||||
partition key
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Partitioning
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text />
|
||||
<Text>
|
||||
Non-hierarchical
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<StyledMessageBar
|
||||
messageBarType={5}
|
||||
>
|
||||
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process.
|
||||
<StyledLinkBase
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
|
||||
target="_blank"
|
||||
underline={true}
|
||||
>
|
||||
Learn more
|
||||
</StyledLinkBase>
|
||||
</StyledMessageBar>
|
||||
<Text>
|
||||
To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container.
|
||||
</Text>
|
||||
<CustomizedPrimaryButton
|
||||
onClick={[Function]}
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"width": "fit-content",
|
||||
},
|
||||
}
|
||||
}
|
||||
text="Change"
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"maxWidth": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Current
|
||||
partition key
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Partitioning
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text />
|
||||
<Text>
|
||||
Non-hierarchical
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { isFabricNative } from "../../../Platform/Fabric/FabricUtil";
|
||||
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
|
||||
|
||||
const zeroValue = 0;
|
||||
@@ -57,6 +58,7 @@ export enum SettingsV2TabTypes {
|
||||
ComputedPropertiesTab,
|
||||
ContainerVectorPolicyTab,
|
||||
ThroughputBucketsTab,
|
||||
GlobalSecondaryIndexTab,
|
||||
}
|
||||
|
||||
export enum ContainerPolicyTabTypes {
|
||||
@@ -164,13 +166,15 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
|
||||
case SettingsV2TabTypes.IndexingPolicyTab:
|
||||
return "Indexing Policy";
|
||||
case SettingsV2TabTypes.PartitionKeyTab:
|
||||
return "Partition Keys (preview)";
|
||||
return isFabricNative() ? "Partition Keys" : "Partition Keys (preview)";
|
||||
case SettingsV2TabTypes.ComputedPropertiesTab:
|
||||
return "Computed Properties";
|
||||
case SettingsV2TabTypes.ContainerVectorPolicyTab:
|
||||
return "Container Policies";
|
||||
case SettingsV2TabTypes.ThroughputBucketsTab:
|
||||
return "Throughput Buckets";
|
||||
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
|
||||
return "Global Secondary Index (Preview)";
|
||||
default:
|
||||
throw new Error(`Unknown tab ${tab}`);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,15 @@ export const collection = {
|
||||
]),
|
||||
vectorEmbeddingPolicy: ko.observable<DataModels.VectorEmbeddingPolicy>({} as DataModels.VectorEmbeddingPolicy),
|
||||
fullTextPolicy: ko.observable<DataModels.FullTextPolicy>({} as DataModels.FullTextPolicy),
|
||||
materializedViews: ko.observable<DataModels.MaterializedView[]>([
|
||||
{ id: "view1", _rid: "rid1" },
|
||||
{ id: "view2", _rid: "rid2" },
|
||||
]),
|
||||
materializedViewDefinition: ko.observable<DataModels.MaterializedViewDefinition>({
|
||||
definition: "SELECT * FROM c WHERE c.id = 1",
|
||||
sourceCollectionId: "source1",
|
||||
sourceCollectionRid: "rid123",
|
||||
}),
|
||||
readSettings: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -60,6 +60,8 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"getDatabase": [Function],
|
||||
"id": [Function],
|
||||
"indexingPolicy": [Function],
|
||||
"materializedViewDefinition": [Function],
|
||||
"materializedViews": [Function],
|
||||
"offer": [Function],
|
||||
"partitionKey": {
|
||||
"kind": "hash",
|
||||
@@ -77,6 +79,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
}
|
||||
isAutoPilotSelected={false}
|
||||
isFixedContainer={false}
|
||||
isGlobalSecondaryIndex={true}
|
||||
onAutoPilotSelected={[Function]}
|
||||
onMaxAutoPilotThroughputChange={[Function]}
|
||||
onScaleDiscardableChange={[Function]}
|
||||
@@ -139,6 +142,8 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"getDatabase": [Function],
|
||||
"id": [Function],
|
||||
"indexingPolicy": [Function],
|
||||
"materializedViewDefinition": [Function],
|
||||
"materializedViews": [Function],
|
||||
"offer": [Function],
|
||||
"partitionKey": {
|
||||
"kind": "hash",
|
||||
@@ -258,6 +263,138 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"getDatabase": [Function],
|
||||
"id": [Function],
|
||||
"indexingPolicy": [Function],
|
||||
"materializedViewDefinition": [Function],
|
||||
"materializedViews": [Function],
|
||||
"offer": [Function],
|
||||
"partitionKey": {
|
||||
"kind": "hash",
|
||||
"paths": [],
|
||||
"version": 2,
|
||||
},
|
||||
"partitionKeyProperties": [
|
||||
"partitionKey",
|
||||
],
|
||||
"readSettings": [Function],
|
||||
"uniqueKeyPolicy": {},
|
||||
"usageSizeInKB": [Function],
|
||||
"vectorEmbeddingPolicy": [Function],
|
||||
}
|
||||
}
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"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],
|
||||
"copyNotebook": [Function],
|
||||
"parameters": [Function],
|
||||
},
|
||||
}
|
||||
}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
</PivotItem>
|
||||
<PivotItem
|
||||
headerText="Computed Properties"
|
||||
itemKey="ComputedPropertiesTab"
|
||||
key="ComputedPropertiesTab"
|
||||
style={
|
||||
{
|
||||
"marginTop": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ComputedPropertiesComponent
|
||||
computedPropertiesContent={
|
||||
[
|
||||
{
|
||||
"name": "queryName",
|
||||
"query": "query",
|
||||
},
|
||||
]
|
||||
}
|
||||
computedPropertiesContentBaseline={
|
||||
[
|
||||
{
|
||||
"name": "queryName",
|
||||
"query": "query",
|
||||
},
|
||||
]
|
||||
}
|
||||
logComputedPropertiesSuccessMessage={[Function]}
|
||||
onComputedPropertiesContentChange={[Function]}
|
||||
onComputedPropertiesDirtyChange={[Function]}
|
||||
resetShouldDiscardComputedProperties={[Function]}
|
||||
shouldDiscardComputedProperties={false}
|
||||
/>
|
||||
</PivotItem>
|
||||
<PivotItem
|
||||
headerText="Global Secondary Index (Preview)"
|
||||
itemKey="GlobalSecondaryIndexTab"
|
||||
key="GlobalSecondaryIndexTab"
|
||||
style={
|
||||
{
|
||||
"marginTop": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<GlobalSecondaryIndexComponent
|
||||
collection={
|
||||
{
|
||||
"analyticalStorageTtl": [Function],
|
||||
"changeFeedPolicy": [Function],
|
||||
"computedProperties": [Function],
|
||||
"conflictResolutionPolicy": [Function],
|
||||
"container": Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"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],
|
||||
"copyNotebook": [Function],
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
"geospatialConfig": [Function],
|
||||
"getDatabase": [Function],
|
||||
"id": [Function],
|
||||
"indexingPolicy": [Function],
|
||||
"materializedViewDefinition": [Function],
|
||||
"materializedViews": [Function],
|
||||
"offer": [Function],
|
||||
"partitionKey": {
|
||||
"kind": "hash",
|
||||
@@ -302,40 +439,6 @@ exports[`SettingsComponent renders 1`] = `
|
||||
}
|
||||
/>
|
||||
</PivotItem>
|
||||
<PivotItem
|
||||
headerText="Computed Properties"
|
||||
itemKey="ComputedPropertiesTab"
|
||||
key="ComputedPropertiesTab"
|
||||
style={
|
||||
{
|
||||
"marginTop": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ComputedPropertiesComponent
|
||||
computedPropertiesContent={
|
||||
[
|
||||
{
|
||||
"name": "queryName",
|
||||
"query": "query",
|
||||
},
|
||||
]
|
||||
}
|
||||
computedPropertiesContentBaseline={
|
||||
[
|
||||
{
|
||||
"name": "queryName",
|
||||
"query": "query",
|
||||
},
|
||||
]
|
||||
}
|
||||
logComputedPropertiesSuccessMessage={[Function]}
|
||||
onComputedPropertiesContentChange={[Function]}
|
||||
onComputedPropertiesDirtyChange={[Function]}
|
||||
resetShouldDiscardComputedProperties={[Function]}
|
||||
shouldDiscardComputedProperties={false}
|
||||
/>
|
||||
</PivotItem>
|
||||
</StyledPivot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ThroughputInputProps {
|
||||
isFreeTier: boolean;
|
||||
showFreeTierExceedThroughputTooltip: boolean;
|
||||
isQuickstart?: boolean;
|
||||
isGlobalSecondaryIndex?: boolean;
|
||||
setThroughputValue: (throughput: number) => void;
|
||||
setIsAutoscale: (isAutoscale: boolean) => void;
|
||||
setIsThroughputCapExceeded: (isThroughputCapExceeded: boolean) => void;
|
||||
@@ -30,6 +31,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
|
||||
isFreeTier,
|
||||
showFreeTierExceedThroughputTooltip,
|
||||
isQuickstart,
|
||||
isGlobalSecondaryIndex,
|
||||
setThroughputValue,
|
||||
setIsAutoscale,
|
||||
setIsThroughputCapExceeded,
|
||||
@@ -193,41 +195,41 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
|
||||
</Text>
|
||||
<InfoTooltip>{PricingUtils.getRuToolTipText()}</InfoTooltip>
|
||||
</Stack>
|
||||
{!isGlobalSecondaryIndex && (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<div role="radiogroup">
|
||||
<input
|
||||
id="Autoscale-input"
|
||||
className="throughputInputRadioBtn"
|
||||
aria-label={`${getThroughputLabelText()} Autoscale`}
|
||||
aria-required={true}
|
||||
checked={isAutoscaleSelected}
|
||||
type="radio"
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={(e) => handleOnChangeMode(e, "Autoscale")}
|
||||
/>
|
||||
<label htmlFor="Autoscale-input" className="throughputInputRadioBtnLabel">
|
||||
Autoscale
|
||||
</label>
|
||||
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<div role="radiogroup">
|
||||
<input
|
||||
id="Autoscale-input"
|
||||
className="throughputInputRadioBtn"
|
||||
aria-label={`${getThroughputLabelText()} Autoscale`}
|
||||
aria-required={true}
|
||||
checked={isAutoscaleSelected}
|
||||
type="radio"
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={(e) => handleOnChangeMode(e, "Autoscale")}
|
||||
/>
|
||||
<label htmlFor="Autoscale-input" className="throughputInputRadioBtnLabel">
|
||||
Autoscale
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="Manual-input"
|
||||
className="throughputInputRadioBtn"
|
||||
aria-label={`${getThroughputLabelText()} Manual`}
|
||||
checked={!isAutoscaleSelected}
|
||||
type="radio"
|
||||
aria-required={true}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={(e) => handleOnChangeMode(e, "Manual")}
|
||||
/>
|
||||
<label className="throughputInputRadioBtnLabel" htmlFor="Manual-input">
|
||||
Manual
|
||||
</label>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<input
|
||||
id="Manual-input"
|
||||
className="throughputInputRadioBtn"
|
||||
aria-label={`${getThroughputLabelText()} Manual`}
|
||||
checked={!isAutoscaleSelected}
|
||||
type="radio"
|
||||
aria-required={true}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={(e) => handleOnChangeMode(e, "Manual")}
|
||||
/>
|
||||
<label className="throughputInputRadioBtnLabel" htmlFor="Manual-input">
|
||||
Manual
|
||||
</label>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
{isAutoscaleSelected && (
|
||||
<Stack className="throughputInputSpacing">
|
||||
<Text variant="small" aria-label="capacity calculator of azure cosmos db">
|
||||
|
||||
@@ -6,6 +6,7 @@ import Explorer from "../Explorer";
|
||||
import { useDatabases } from "../useDatabases";
|
||||
import { ContainerSampleGenerator } from "./ContainerSampleGenerator";
|
||||
|
||||
// TODO: this does not seem to be used. Remove?
|
||||
export class DataSamplesUtil {
|
||||
private static readonly DialogTitle = "Create Sample Container";
|
||||
constructor(private container: Explorer) {}
|
||||
|
||||
@@ -55,7 +55,7 @@ import type NotebookManager from "./Notebook/NotebookManager";
|
||||
import { NotebookPaneContent } from "./Notebook/NotebookManager";
|
||||
import { NotebookUtil } from "./Notebook/NotebookUtil";
|
||||
import { useNotebook } from "./Notebook/useNotebook";
|
||||
import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
|
||||
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
|
||||
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
|
||||
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
|
||||
import { StringInputPane } from "./Panes/StringInputPane/StringInputPane";
|
||||
@@ -910,7 +910,9 @@ export default class Explorer {
|
||||
}
|
||||
|
||||
public async openNotebookTerminal(kind: ViewModels.TerminalKind): Promise<void> {
|
||||
if (useNotebook.getState().isPhoenixFeatures) {
|
||||
if (userContext.features.enableCloudShell) {
|
||||
this.connectToNotebookTerminal(kind);
|
||||
} else if (useNotebook.getState().isPhoenixFeatures) {
|
||||
await this.allocateContainer(PoolIdType.DefaultPoolId);
|
||||
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
|
||||
if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) {
|
||||
|
||||
@@ -126,13 +126,14 @@ export function createContextCommandBarButtons(
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
|
||||
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
|
||||
const label = useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell";
|
||||
const label =
|
||||
useNotebook.getState().isShellEnabled || 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) {
|
||||
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
|
||||
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
|
||||
} else {
|
||||
selectedCollection && selectedCollection.onNewMongoShellClick();
|
||||
@@ -146,7 +147,7 @@ export function createContextCommandBarButtons(
|
||||
}
|
||||
|
||||
if (
|
||||
useNotebook.getState().isShellEnabled &&
|
||||
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
|
||||
!selectedNodeState.isDatabaseNodeOrNoneSelected() &&
|
||||
userContext.apiType === "Cassandra"
|
||||
) {
|
||||
@@ -455,7 +456,7 @@ function createOpenTerminalButtonByKind(
|
||||
iconSrc: HostedTerminalIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => {
|
||||
if (useNotebook.getState().isNotebookEnabled) {
|
||||
if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) {
|
||||
container.openNotebookTerminal(terminalKind);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import Explorer from "../Explorer";
|
||||
import Explorer from "../../Explorer";
|
||||
import { AddCollectionPanel } from "./AddCollectionPanel";
|
||||
|
||||
const props = {
|
||||
@@ -21,11 +21,25 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import {
|
||||
FullTextPoliciesComponent,
|
||||
getFullTextLanguageOptions,
|
||||
} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
|
||||
import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
|
||||
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
|
||||
import {
|
||||
AllPropertiesIndexed,
|
||||
AnalyticalStorageContent,
|
||||
ContainerVectorPolicyTooltipContent,
|
||||
FullTextPolicyDefault,
|
||||
getPartitionKey,
|
||||
getPartitionKeyName,
|
||||
getPartitionKeyPlaceHolder,
|
||||
getPartitionKeyTooltipText,
|
||||
isFreeTierAccount,
|
||||
isSynapseLinkEnabled,
|
||||
parseUniqueKeys,
|
||||
scrollToSection,
|
||||
SharedDatabaseDefault,
|
||||
shouldShowAnalyticalStoreOptions,
|
||||
UniqueKeysHeader,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
@@ -42,15 +56,15 @@ import {
|
||||
isVectorSearchEnabled,
|
||||
} from "Utils/CapabilityUtils";
|
||||
import { getUpsellMessage } from "Utils/PricingUtils";
|
||||
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput";
|
||||
import "../Controls/ThroughputInput/ThroughputInput.less";
|
||||
import { ContainerSampleGenerator } from "../DataSamples/ContainerSampleGenerator";
|
||||
import Explorer from "../Explorer";
|
||||
import { useDatabases } from "../useDatabases";
|
||||
import { PanelFooterComponent } from "./PanelFooterComponent";
|
||||
import { PanelInfoErrorComponent } from "./PanelInfoErrorComponent";
|
||||
import { PanelLoadingScreen } from "./PanelLoadingScreen";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import { CollapsibleSectionComponent } from "../../Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
|
||||
import { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator";
|
||||
import Explorer from "../../Explorer";
|
||||
import { useDatabases } from "../../useDatabases";
|
||||
import { PanelFooterComponent } from "../PanelFooterComponent";
|
||||
import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent";
|
||||
import { PanelLoadingScreen } from "../PanelLoadingScreen";
|
||||
|
||||
export interface AddCollectionPanelProps {
|
||||
explorer: Explorer;
|
||||
@@ -58,40 +72,6 @@ export interface AddCollectionPanelProps {
|
||||
isQuickstart?: boolean;
|
||||
}
|
||||
|
||||
const SharedDatabaseDefault: DataModels.IndexingPolicy = {
|
||||
indexingMode: "consistent",
|
||||
automatic: true,
|
||||
includedPaths: [],
|
||||
excludedPaths: [
|
||||
{
|
||||
path: "/*",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
|
||||
indexingMode: "consistent",
|
||||
automatic: true,
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/*",
|
||||
indexes: [
|
||||
{
|
||||
kind: "Range",
|
||||
dataType: "Number",
|
||||
precision: -1,
|
||||
},
|
||||
{
|
||||
kind: "Range",
|
||||
dataType: "String",
|
||||
precision: -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
excludedPaths: [],
|
||||
};
|
||||
|
||||
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
|
||||
vectorEmbeddings: [],
|
||||
};
|
||||
@@ -144,7 +124,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
|
||||
enableIndexing: true,
|
||||
isSharded: userContext.apiType !== "Tables",
|
||||
partitionKey: this.getPartitionKey(),
|
||||
partitionKey: getPartitionKey(props.isQuickstart),
|
||||
subPartitionKeys: [],
|
||||
enableDedicatedThroughput: false,
|
||||
createMongoWildCardIndex:
|
||||
@@ -160,7 +140,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
vectorEmbeddingPolicy: [],
|
||||
vectorIndexingPolicy: [],
|
||||
vectorPolicyValidated: true,
|
||||
fullTextPolicy: { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] },
|
||||
fullTextPolicy: FullTextPolicyDefault,
|
||||
fullTextIndexes: [],
|
||||
fullTextPolicyValidated: true,
|
||||
};
|
||||
@@ -174,7 +154,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
componentDidUpdate(_prevProps: AddCollectionPanelProps, prevState: AddCollectionPanelState): void {
|
||||
if (this.state.errorMessage && this.state.errorMessage !== prevState.errorMessage) {
|
||||
this.scrollToSection("panelContainer");
|
||||
scrollToSection("panelContainer");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +171,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
/>
|
||||
)}
|
||||
|
||||
{!this.state.errorMessage && this.isFreeTierAccount() && (
|
||||
{!this.state.errorMessage && isFreeTierAccount() && (
|
||||
<PanelInfoErrorComponent
|
||||
message={getUpsellMessage(userContext.portalEnv, true, isFirstResourceCreated, true)}
|
||||
messageType="info"
|
||||
@@ -351,8 +331,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
required
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder="Type a new database id"
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
@@ -399,10 +379,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
{!isServerlessAccount() && this.state.isSharedThroughputChecked && (
|
||||
<ThroughputInput
|
||||
showFreeTierExceedThroughputTooltip={this.isFreeTierAccount() && !isFirstResourceCreated}
|
||||
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !isFirstResourceCreated}
|
||||
isDatabase={true}
|
||||
isSharded={this.state.isSharded}
|
||||
isFreeTier={this.isFreeTierAccount()}
|
||||
isFreeTier={isFreeTierAccount()}
|
||||
isQuickstart={this.props.isQuickstart}
|
||||
setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)}
|
||||
setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)}
|
||||
@@ -459,8 +439,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., ${getCollectionName()}1`}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
@@ -579,17 +559,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{this.getPartitionKeyName()}
|
||||
{getPartitionKeyName()}
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={this.getPartitionKeyTooltipText()}
|
||||
>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={getPartitionKeyTooltipText()}>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel={this.getPartitionKeyTooltipText()}
|
||||
ariaLabel={getPartitionKeyTooltipText()}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -603,8 +580,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
required
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
placeholder={this.getPartitionKeyPlaceHolder()}
|
||||
aria-label={this.getPartitionKeyName()}
|
||||
placeholder={getPartitionKeyPlaceHolder()}
|
||||
aria-label={getPartitionKeyName()}
|
||||
pattern={userContext.apiType === "Gremlin" ? "^/[^/]*" : ".*"}
|
||||
title={userContext.apiType === "Gremlin" ? "May not use composite partition key" : ""}
|
||||
value={this.state.partitionKey}
|
||||
@@ -642,8 +619,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={index > 0 ? 1 : 0}
|
||||
className="panelTextField"
|
||||
autoComplete="off"
|
||||
placeholder={this.getPartitionKeyPlaceHolder(index)}
|
||||
aria-label={this.getPartitionKeyName()}
|
||||
placeholder={getPartitionKeyPlaceHolder(index)}
|
||||
aria-label={getPartitionKeyName()}
|
||||
pattern={".*"}
|
||||
title={""}
|
||||
value={subPartitionKey}
|
||||
@@ -734,10 +711,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
{this.shouldShowCollectionThroughputInput() && (
|
||||
<ThroughputInput
|
||||
showFreeTierExceedThroughputTooltip={this.isFreeTierAccount() && !isFirstResourceCreated}
|
||||
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !isFirstResourceCreated}
|
||||
isDatabase={false}
|
||||
isSharded={this.state.isSharded}
|
||||
isFreeTier={this.isFreeTierAccount()}
|
||||
isFreeTier={isFreeTierAccount()}
|
||||
isQuickstart={this.props.isQuickstart}
|
||||
setThroughputValue={(throughput: number) => (this.collectionThroughput = throughput)}
|
||||
setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)}
|
||||
@@ -752,27 +729,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
{!isFabricNative() && userContext.apiType === "SQL" && (
|
||||
<Stack>
|
||||
<Stack horizontal>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Unique keys
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={
|
||||
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key."
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel={
|
||||
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key."
|
||||
}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
|
||||
{UniqueKeysHeader()}
|
||||
{this.state.uniqueKeys.map((uniqueKey: string, i: number): JSX.Element => {
|
||||
return (
|
||||
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${i}`} horizontal>
|
||||
@@ -820,10 +777,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{this.shouldShowAnalyticalStoreOptions() && (
|
||||
{shouldShowAnalyticalStoreOptions() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{this.getAnalyticalStorageContent()}
|
||||
{AnalyticalStorageContent()}
|
||||
</Text>
|
||||
|
||||
<Stack horizontal verticalAlign="center">
|
||||
@@ -831,7 +788,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={this.state.enableAnalyticalStore}
|
||||
disabled={!this.isSynapseLinkEnabled()}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Enable analytical store"
|
||||
aria-checked={this.state.enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
@@ -846,7 +803,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!this.state.enableAnalyticalStore}
|
||||
disabled={!this.isSynapseLinkEnabled()}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Disable analytical store"
|
||||
aria-checked={!this.state.enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
@@ -860,7 +817,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{!this.isSynapseLinkEnabled() && (
|
||||
{!isSynapseLinkEnabled() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text variant="small">
|
||||
Azure Synapse Link is required for creating an analytical store{" "}
|
||||
@@ -890,9 +847,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
title="Container Vector Policy"
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
this.scrollToSection("collapsibleVectorPolicySectionContent");
|
||||
scrollToSection("collapsibleVectorPolicySectionContent");
|
||||
}}
|
||||
tooltipContent={this.getContainerVectorPolicyTooltipContent()}
|
||||
tooltipContent={ContainerVectorPolicyTooltipContent()}
|
||||
>
|
||||
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
||||
<Stack styles={{ root: { paddingLeft: 40 } }}>
|
||||
@@ -918,7 +875,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
title="Container Full Text Search Policy"
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
this.scrollToSection("collapsibleFullTextPolicySectionContent");
|
||||
scrollToSection("collapsibleFullTextPolicySectionContent");
|
||||
}}
|
||||
//TODO: uncomment when learn more text becomes available
|
||||
// tooltipContent={this.getContainerFullTextPolicyTooltipContent()}
|
||||
@@ -946,7 +903,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
|
||||
this.scrollToSection("collapsibleAdvancedSectionContent");
|
||||
scrollToSection("collapsibleAdvancedSectionContent");
|
||||
}}
|
||||
>
|
||||
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
|
||||
@@ -1056,31 +1013,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
}));
|
||||
}
|
||||
|
||||
private getPartitionKeyName(isLowerCase?: boolean): string {
|
||||
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
|
||||
|
||||
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
|
||||
}
|
||||
|
||||
private getPartitionKeyPlaceHolder(index?: number): string {
|
||||
switch (userContext.apiType) {
|
||||
case "Mongo":
|
||||
return "e.g., categoryId";
|
||||
case "Gremlin":
|
||||
return "e.g., /address";
|
||||
case "SQL":
|
||||
return `${
|
||||
index === undefined
|
||||
? "Required - first partition key e.g., /TenantId"
|
||||
: index === 0
|
||||
? "second partition key e.g., /UserId"
|
||||
: "third partition key e.g., /SessionId"
|
||||
}`;
|
||||
default:
|
||||
return "e.g., /address/zipCode";
|
||||
}
|
||||
}
|
||||
|
||||
private onCreateNewDatabaseRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
if (event.target.checked && !this.state.createNewDatabase) {
|
||||
this.setState({
|
||||
@@ -1168,48 +1100,12 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
return !!selectedDatabase?.offer();
|
||||
}
|
||||
|
||||
private isFreeTierAccount(): boolean {
|
||||
return userContext.databaseAccount?.properties?.enableFreeTier;
|
||||
}
|
||||
|
||||
private getFreeTierIndexingText(): string {
|
||||
return this.state.enableIndexing
|
||||
? "All properties in your documents will be indexed by default for flexible and efficient queries."
|
||||
: "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations.";
|
||||
}
|
||||
|
||||
private getPartitionKeyTooltipText(): string {
|
||||
if (userContext.apiType === "Mongo") {
|
||||
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. It’s critical to choose a field that will evenly distribute your data.";
|
||||
}
|
||||
|
||||
let tooltipText = `The ${this.getPartitionKeyName(
|
||||
true,
|
||||
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
|
||||
}
|
||||
|
||||
return tooltipText;
|
||||
}
|
||||
|
||||
private getPartitionKey(): string {
|
||||
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
|
||||
return "";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault) {
|
||||
return userContext.apiType === "SQL" ? "/id" : "_id";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault2) {
|
||||
return userContext.apiType === "SQL" ? "/pk" : "pk";
|
||||
}
|
||||
if (this.props.isQuickstart) {
|
||||
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private getPartitionKeySubtext(): string {
|
||||
if (
|
||||
userContext.features.partitionKeyDefault &&
|
||||
@@ -1221,34 +1117,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
return "";
|
||||
}
|
||||
|
||||
private getAnalyticalStorageContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational data, without
|
||||
impacting the performance of transactional workloads.{" "}
|
||||
<Link
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
|
||||
target="_blank"
|
||||
href="https://aka.ms/analytical-store-overview"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
private getContainerVectorPolicyTooltipContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Describe any properties in your data that contain vectors, so that they can be made available for similarity
|
||||
queries.{" "}
|
||||
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
//TODO: uncomment when learn more text becomes available
|
||||
// private getContainerFullTextPolicyTooltipContent(): JSX.Element {
|
||||
// return (
|
||||
@@ -1279,7 +1147,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
}
|
||||
|
||||
private shouldShowIndexingOptionsForFreeTierAccount(): boolean {
|
||||
if (!this.isFreeTierAccount()) {
|
||||
if (!isFreeTierAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1288,39 +1156,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
: this.isSelectedDatabaseSharedThroughput();
|
||||
}
|
||||
|
||||
private shouldShowAnalyticalStoreOptions(): boolean {
|
||||
if (isFabricNative() || configContext.platform === Platform.Emulator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "SQL":
|
||||
case "Mongo":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isSynapseLinkEnabled(): boolean {
|
||||
if (!userContext.databaseAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { properties } = userContext.databaseAccount;
|
||||
if (!properties) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (properties.enableAnalyticalStorage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return properties.capabilities?.some(
|
||||
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
|
||||
);
|
||||
}
|
||||
|
||||
private shouldShowVectorSearchParameters() {
|
||||
return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput());
|
||||
}
|
||||
@@ -1401,11 +1236,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
}
|
||||
|
||||
private getAnalyticalStorageTtl(): number {
|
||||
if (!this.isSynapseLinkEnabled()) {
|
||||
if (!isSynapseLinkEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.shouldShowAnalyticalStoreOptions()) {
|
||||
if (!shouldShowAnalyticalStoreOptions()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1419,10 +1254,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
return Constants.AnalyticalStorageTtl.Disabled;
|
||||
}
|
||||
|
||||
private scrollToSection(id: string): void {
|
||||
document.getElementById(id)?.scrollIntoView();
|
||||
}
|
||||
|
||||
private getSampleDBName(): string {
|
||||
const existingSampleDBs = useDatabases
|
||||
.getState()
|
||||
@@ -1457,7 +1288,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
partitionKeyString = "/'$pk'";
|
||||
}
|
||||
|
||||
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys();
|
||||
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(this.state.uniqueKeys);
|
||||
const partitionKeyVersion = this.state.useHashV1 ? undefined : 2;
|
||||
const partitionKey: DataModels.PartitionKey = partitionKeyString
|
||||
? {
|
||||
@@ -0,0 +1,217 @@
|
||||
import { DirectionalHint, Icon, Link, Stack, Text, TooltipHost } from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import { getFullTextLanguageOptions } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
|
||||
export function getPartitionKeyTooltipText(): string {
|
||||
if (userContext.apiType === "Mongo") {
|
||||
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. It’s critical to choose a field that will evenly distribute your data.";
|
||||
}
|
||||
|
||||
let tooltipText = `The ${getPartitionKeyName(
|
||||
true,
|
||||
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
|
||||
}
|
||||
|
||||
return tooltipText;
|
||||
}
|
||||
|
||||
export function getPartitionKeyName(isLowerCase?: boolean): string {
|
||||
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
|
||||
|
||||
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
|
||||
}
|
||||
|
||||
export function getPartitionKeyPlaceHolder(index?: number): string {
|
||||
switch (userContext.apiType) {
|
||||
case "Mongo":
|
||||
return "e.g., categoryId";
|
||||
case "Gremlin":
|
||||
return "e.g., /address";
|
||||
case "SQL":
|
||||
return `${
|
||||
index === undefined
|
||||
? "Required - first partition key e.g., /TenantId"
|
||||
: index === 0
|
||||
? "second partition key e.g., /UserId"
|
||||
: "third partition key e.g., /SessionId"
|
||||
}`;
|
||||
default:
|
||||
return "e.g., /address/zipCode";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPartitionKey(isQuickstart?: boolean): string {
|
||||
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
|
||||
return "";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault) {
|
||||
return userContext.apiType === "SQL" ? "/id" : "_id";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault2) {
|
||||
return userContext.apiType === "SQL" ? "/pk" : "pk";
|
||||
}
|
||||
if (isQuickstart) {
|
||||
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isFreeTierAccount(): boolean {
|
||||
return userContext.databaseAccount?.properties?.enableFreeTier;
|
||||
}
|
||||
|
||||
export function UniqueKeysHeader(): JSX.Element {
|
||||
const tooltipContent =
|
||||
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key.";
|
||||
|
||||
return (
|
||||
<Stack horizontal>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Unique keys
|
||||
</Text>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
|
||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowAnalyticalStoreOptions(): boolean {
|
||||
if (isFabricNative() || configContext.platform === Platform.Emulator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "SQL":
|
||||
case "Mongo":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AnalyticalStorageContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
|
||||
the performance of transactional workloads.{" "}
|
||||
<Link
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
|
||||
target="_blank"
|
||||
href="https://aka.ms/analytical-store-overview"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function isSynapseLinkEnabled(): boolean {
|
||||
if (!userContext.databaseAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { properties } = userContext.databaseAccount;
|
||||
if (!properties) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (properties.enableAnalyticalStorage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return properties.capabilities?.some(
|
||||
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
|
||||
);
|
||||
}
|
||||
|
||||
export function scrollToSection(id: string): void {
|
||||
document.getElementById(id)?.scrollIntoView();
|
||||
}
|
||||
|
||||
export function ContainerVectorPolicyTooltipContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Describe any properties in your data that contain vectors, so that they can be made available for similarity
|
||||
queries.{" "}
|
||||
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseUniqueKeys(uniqueKeys: string[]): DataModels.UniqueKeyPolicy {
|
||||
if (uniqueKeys?.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] };
|
||||
uniqueKeys.forEach((uniqueKey: string) => {
|
||||
if (uniqueKey) {
|
||||
const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0);
|
||||
const trimmedPaths: string[] = validPaths?.map((path) => path.trim());
|
||||
if (trimmedPaths?.length > 0) {
|
||||
if (userContext.apiType === "Mongo") {
|
||||
trimmedPaths.map((path) => {
|
||||
const transformedPath = path.split(".").join("/");
|
||||
if (transformedPath[0] !== "/") {
|
||||
return "/" + transformedPath;
|
||||
}
|
||||
return transformedPath;
|
||||
});
|
||||
}
|
||||
uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueKeyPolicy;
|
||||
}
|
||||
|
||||
export const SharedDatabaseDefault: DataModels.IndexingPolicy = {
|
||||
indexingMode: "consistent",
|
||||
automatic: true,
|
||||
includedPaths: [],
|
||||
excludedPaths: [
|
||||
{
|
||||
path: "/*",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const FullTextPolicyDefault: DataModels.FullTextPolicy = {
|
||||
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
|
||||
fullTextPaths: [],
|
||||
};
|
||||
|
||||
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
|
||||
indexingMode: "consistent",
|
||||
automatic: true,
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/*",
|
||||
indexes: [
|
||||
{
|
||||
kind: "Range",
|
||||
dataType: "Number",
|
||||
precision: -1,
|
||||
},
|
||||
{
|
||||
kind: "Range",
|
||||
dataType: "String",
|
||||
precision: -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
excludedPaths: [],
|
||||
};
|
||||
@@ -93,7 +93,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
id="newDatabaseId"
|
||||
name="newDatabaseId"
|
||||
onChange={[Function]}
|
||||
pattern="[^/?#\\\\]*[^/?# \\\\]"
|
||||
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
|
||||
placeholder="Type a new database id"
|
||||
required={true}
|
||||
size={40}
|
||||
@@ -178,7 +178,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
id="collectionId"
|
||||
name="collectionId"
|
||||
onChange={[Function]}
|
||||
pattern="[^/?#\\\\]*[^/?# \\\\]"
|
||||
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
|
||||
placeholder="e.g., Container1"
|
||||
required={true}
|
||||
size={40}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
|
||||
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
|
||||
@@ -204,8 +205,8 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
||||
type="text"
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
size={40}
|
||||
aria-label={databaseIdLabel}
|
||||
placeholder={databaseIdPlaceHolder}
|
||||
|
||||
@@ -39,7 +39,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
|
||||
data-lpignore={true}
|
||||
id="database-id"
|
||||
onChange={[Function]}
|
||||
pattern="[^/?#\\\\]*[^/?# \\\\]"
|
||||
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
|
||||
placeholder="Type a new database id"
|
||||
size={40}
|
||||
styles={
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { shallow, ShallowWrapper } from "enzyme";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import {
|
||||
AddGlobalSecondaryIndexPanel,
|
||||
AddGlobalSecondaryIndexPanelProps,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
|
||||
import React, { Component } from "react";
|
||||
|
||||
const props: AddGlobalSecondaryIndexPanelProps = {
|
||||
explorer: new Explorer(),
|
||||
};
|
||||
|
||||
describe("AddGlobalSecondaryIndexPanel", () => {
|
||||
it("render default panel", () => {
|
||||
const wrapper: ShallowWrapper<AddGlobalSecondaryIndexPanelProps, object, Component> = shallow(
|
||||
<AddGlobalSecondaryIndexPanel {...props} />,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render form", () => {
|
||||
const wrapper: ShallowWrapper<AddGlobalSecondaryIndexPanelProps, object, Component> = shallow(
|
||||
<AddGlobalSecondaryIndexPanel {...props} />,
|
||||
);
|
||||
const form = wrapper.find("form").first();
|
||||
expect(form).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
import {
|
||||
DirectionalHint,
|
||||
Dropdown,
|
||||
DropdownMenuItemType,
|
||||
Icon,
|
||||
IDropdownOption,
|
||||
Link,
|
||||
Separator,
|
||||
Stack,
|
||||
Text,
|
||||
TooltipHost,
|
||||
} from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { createGlobalSecondaryIndex } from "Common/dataAccess/createMaterializedView";
|
||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import { FullTextIndex, FullTextPolicy, VectorEmbedding, VectorIndex } from "Contracts/DataModels";
|
||||
import { Collection, Database } from "Contracts/ViewModels";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import {
|
||||
AllPropertiesIndexed,
|
||||
FullTextPolicyDefault,
|
||||
getPartitionKey,
|
||||
isSynapseLinkEnabled,
|
||||
scrollToSection,
|
||||
shouldShowAnalyticalStoreOptions,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import {
|
||||
chooseSourceContainerStyle,
|
||||
chooseSourceContainerStyles,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles";
|
||||
import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent";
|
||||
import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent";
|
||||
import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent";
|
||||
import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent";
|
||||
import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent";
|
||||
import { VectorSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent";
|
||||
import { PanelFooterComponent } from "Explorer/Panes/PanelFooterComponent";
|
||||
import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent";
|
||||
import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CollectionCreation } from "Shared/Constants";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "UserContext";
|
||||
import { isFullTextSearchEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
|
||||
export interface AddGlobalSecondaryIndexPanelProps {
|
||||
explorer: Explorer;
|
||||
sourceContainer?: Collection;
|
||||
}
|
||||
export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanelProps): JSX.Element => {
|
||||
const { explorer, sourceContainer } = props;
|
||||
|
||||
const [sourceContainerOptions, setSourceContainerOptions] = useState<IDropdownOption[]>();
|
||||
const [selectedSourceContainer, setSelectedSourceContainer] = useState<Collection>(sourceContainer);
|
||||
const [globalSecondaryIndexId, setGlobalSecondaryIndexId] = useState<string>();
|
||||
const [definition, setDefinition] = useState<string>();
|
||||
const [partitionKey, setPartitionKey] = useState<string>(getPartitionKey());
|
||||
const [subPartitionKeys, setSubPartitionKeys] = useState<string[]>([]);
|
||||
const [useHashV1, setUseHashV1] = useState<boolean>();
|
||||
const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState<boolean>();
|
||||
const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState<boolean>();
|
||||
const [enableAnalyticalStore, setEnableAnalyticalStore] = useState<boolean>();
|
||||
const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState<VectorEmbedding[]>([]);
|
||||
const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState<VectorIndex[]>([]);
|
||||
const [vectorPolicyValidated, setVectorPolicyValidated] = useState<boolean>(true);
|
||||
const [fullTextPolicy, setFullTextPolicy] = useState<FullTextPolicy>(FullTextPolicyDefault);
|
||||
const [fullTextIndexes, setFullTextIndexes] = useState<FullTextIndex[]>([]);
|
||||
const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [showErrorDetails, setShowErrorDetails] = useState<boolean>();
|
||||
const [isExecuting, setIsExecuting] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
const sourceContainerOptions: IDropdownOption[] = [];
|
||||
useDatabases.getState().databases.forEach((database: Database) => {
|
||||
sourceContainerOptions.push({
|
||||
key: database.rid,
|
||||
text: database.id(),
|
||||
itemType: DropdownMenuItemType.Header,
|
||||
});
|
||||
|
||||
database.collections().forEach((collection: Collection) => {
|
||||
const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition();
|
||||
sourceContainerOptions.push({
|
||||
key: collection.rid,
|
||||
text: collection.id(),
|
||||
disabled: isGlobalSecondaryIndex,
|
||||
...(isGlobalSecondaryIndex && {
|
||||
title: "This is a global secondary index.",
|
||||
}),
|
||||
data: collection,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setSourceContainerOptions(sourceContainerOptions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToSection("panelContainer");
|
||||
}, [errorMessage]);
|
||||
|
||||
let globalSecondaryIndexThroughput: number;
|
||||
let isCostAcknowledged: boolean;
|
||||
|
||||
const globalSecondaryIndexThroughputOnChange = (globalSecondaryIndexThroughputValue: number): void => {
|
||||
globalSecondaryIndexThroughput = globalSecondaryIndexThroughputValue;
|
||||
};
|
||||
|
||||
const isCostAknowledgedOnChange = (isCostAcknowledgedValue: boolean): void => {
|
||||
isCostAcknowledged = isCostAcknowledgedValue;
|
||||
};
|
||||
|
||||
const isSelectedSourceContainerSharedThroughput = (): boolean => {
|
||||
if (!selectedSourceContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!selectedSourceContainer.getDatabase().offer();
|
||||
};
|
||||
|
||||
const showCollectionThroughputInput = (): boolean => {
|
||||
if (isServerlessAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableDedicatedThroughput) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!selectedSourceContainer && !isSelectedSourceContainerSharedThroughput();
|
||||
};
|
||||
|
||||
const showVectorSearchParameters = (): boolean => {
|
||||
return isVectorSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
|
||||
};
|
||||
|
||||
const showFullTextSearchParameters = (): boolean => {
|
||||
return isFullTextSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
|
||||
};
|
||||
|
||||
const getAnalyticalStorageTtl = (): number => {
|
||||
if (!isSynapseLinkEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!shouldShowAnalyticalStoreOptions()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (enableAnalyticalStore) {
|
||||
// TODO: always default to 90 days once the backend hotfix is deployed
|
||||
return userContext.features.ttl90Days
|
||||
? Constants.AnalyticalStorageTtl.Days90
|
||||
: Constants.AnalyticalStorageTtl.Infinite;
|
||||
}
|
||||
|
||||
return Constants.AnalyticalStorageTtl.Disabled;
|
||||
};
|
||||
|
||||
const validateInputs = (): boolean => {
|
||||
if (!selectedSourceContainer) {
|
||||
setErrorMessage("Please select a source container");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
|
||||
const errorMessage: string = "Please acknowledge the estimated monthly spend.";
|
||||
setErrorMessage(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (globalSecondaryIndexThroughput > CollectionCreation.MaxRUPerPartition) {
|
||||
setErrorMessage("Unsharded collections support up to 10,000 RUs");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (showVectorSearchParameters()) {
|
||||
if (!vectorPolicyValidated) {
|
||||
setErrorMessage("Please fix errors in container vector policy");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fullTextPolicyValidated) {
|
||||
setErrorMessage("Please fix errors in container full text search policy");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const submit = async (event?: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!validateInputs()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const globalSecondaryIdTrimmed: string = globalSecondaryIndexId.trim();
|
||||
|
||||
const globalSecondaryIndexDefinition: DataModels.MaterializedViewDefinition = {
|
||||
sourceCollectionId: selectedSourceContainer.id(),
|
||||
definition: definition,
|
||||
};
|
||||
|
||||
const partitionKeyTrimmed: string = partitionKey.trim();
|
||||
|
||||
const partitionKeyVersion = useHashV1 ? undefined : 2;
|
||||
const partitionKeyPaths: DataModels.PartitionKey = partitionKeyTrimmed
|
||||
? {
|
||||
paths: [
|
||||
partitionKeyTrimmed,
|
||||
...(userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? subPartitionKeys : []),
|
||||
],
|
||||
kind: userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? "MultiHash" : "Hash",
|
||||
version: partitionKeyVersion,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const indexingPolicy: DataModels.IndexingPolicy = AllPropertiesIndexed;
|
||||
let vectorEmbeddingPolicyFinal: DataModels.VectorEmbeddingPolicy;
|
||||
|
||||
if (showVectorSearchParameters()) {
|
||||
indexingPolicy.vectorIndexes = vectorIndexingPolicy;
|
||||
vectorEmbeddingPolicyFinal = {
|
||||
vectorEmbeddings: vectorEmbeddingPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
if (showFullTextSearchParameters()) {
|
||||
indexingPolicy.fullTextIndexes = fullTextIndexes;
|
||||
}
|
||||
|
||||
const telemetryData: TelemetryProcessor.TelemetryData = {
|
||||
database: {
|
||||
id: selectedSourceContainer.databaseId,
|
||||
shared: isSelectedSourceContainerSharedThroughput(),
|
||||
},
|
||||
collection: {
|
||||
id: globalSecondaryIdTrimmed,
|
||||
throughput: globalSecondaryIndexThroughput,
|
||||
isAutoscale: true,
|
||||
partitionKeyPaths,
|
||||
collectionWithDedicatedThroughput: enableDedicatedThroughput,
|
||||
},
|
||||
subscriptionQuotaId: userContext.quotaId,
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
};
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData);
|
||||
const databaseLevelThroughput: boolean = isSelectedSourceContainerSharedThroughput() && !enableDedicatedThroughput;
|
||||
|
||||
const createGlobalSecondaryIndexParams: DataModels.CreateMaterializedViewsParams = {
|
||||
materializedViewId: globalSecondaryIdTrimmed,
|
||||
materializedViewDefinition: globalSecondaryIndexDefinition,
|
||||
databaseId: selectedSourceContainer.databaseId,
|
||||
databaseLevelThroughput: databaseLevelThroughput,
|
||||
...(!databaseLevelThroughput && {
|
||||
autoPilotMaxThroughput: globalSecondaryIndexThroughput,
|
||||
}),
|
||||
analyticalStorageTtl: getAnalyticalStorageTtl(),
|
||||
indexingPolicy: indexingPolicy,
|
||||
partitionKey: partitionKeyPaths,
|
||||
vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal,
|
||||
fullTextPolicy: fullTextPolicy,
|
||||
};
|
||||
|
||||
setIsExecuting(true);
|
||||
|
||||
try {
|
||||
await createGlobalSecondaryIndex(createGlobalSecondaryIndexParams);
|
||||
await explorer.refreshAllDatabases();
|
||||
TelemetryProcessor.traceSuccess(Action.CreateGlobalSecondaryIndex, telemetryData, startKey);
|
||||
useSidePanel.getState().closeSidePanel();
|
||||
} catch (error) {
|
||||
const errorMessage: string = getErrorMessage(error);
|
||||
setErrorMessage(errorMessage);
|
||||
setShowErrorDetails(true);
|
||||
const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) };
|
||||
TelemetryProcessor.traceFailure(Action.CreateGlobalSecondaryIndex, failureTelemetryData, startKey);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="panelFormWrapper" id="panelGlobalSecondaryIndex" onSubmit={submit}>
|
||||
{errorMessage && (
|
||||
<PanelInfoErrorComponent message={errorMessage} messageType="error" showErrorDetails={showErrorDetails} />
|
||||
)}
|
||||
<div className="panelMainContent">
|
||||
<Stack>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Source container id
|
||||
</Text>
|
||||
</Stack>
|
||||
<Dropdown
|
||||
placeholder="Choose source container"
|
||||
options={sourceContainerOptions}
|
||||
defaultSelectedKey={selectedSourceContainer?.rid}
|
||||
styles={chooseSourceContainerStyles()}
|
||||
style={chooseSourceContainerStyle()}
|
||||
onChange={(_, options: IDropdownOption) => setSelectedSourceContainer(options.data as Collection)}
|
||||
/>
|
||||
<Separator className="panelSeparator" />
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Global secondary index container id
|
||||
</Text>
|
||||
</Stack>
|
||||
<input
|
||||
id="globalSecondaryIndexId"
|
||||
type="text"
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., indexbyEmailId`}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
value={globalSecondaryIndexId}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGlobalSecondaryIndexId(event.target.value)}
|
||||
/>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Global secondary index definition
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={
|
||||
<Link
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
|
||||
target="blank"
|
||||
>
|
||||
Learn more about defining global secondary indexes.
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Icon role="button" iconName="Info" className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
<input
|
||||
id="globalSecondaryIndexDefinition"
|
||||
type="text"
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder={"SELECT c.email, c.accountId FROM c"}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
value={definition || ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDefinition(event.target.value)}
|
||||
/>
|
||||
<PartitionKeyComponent
|
||||
{...{ partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 }}
|
||||
/>
|
||||
<ThroughputComponent
|
||||
{...{
|
||||
enableDedicatedThroughput,
|
||||
setEnabledDedicatedThroughput,
|
||||
isSelectedSourceContainerSharedThroughput,
|
||||
showCollectionThroughputInput,
|
||||
globalSecondaryIndexThroughputOnChange,
|
||||
setIsThroughputCapExceeded,
|
||||
isCostAknowledgedOnChange,
|
||||
}}
|
||||
/>
|
||||
{shouldShowAnalyticalStoreOptions() && (
|
||||
<AnalyticalStoreComponent {...{ explorer, enableAnalyticalStore, setEnableAnalyticalStore }} />
|
||||
)}
|
||||
{showVectorSearchParameters() && (
|
||||
<VectorSearchComponent
|
||||
{...{
|
||||
vectorEmbeddingPolicy,
|
||||
setVectorEmbeddingPolicy,
|
||||
vectorIndexingPolicy,
|
||||
setVectorIndexingPolicy,
|
||||
vectorPolicyValidated,
|
||||
setVectorPolicyValidated,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showFullTextSearchParameters() && (
|
||||
<FullTextSearchComponent
|
||||
{...{ fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated }}
|
||||
/>
|
||||
)}
|
||||
<AdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
|
||||
</Stack>
|
||||
</div>
|
||||
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={isThroughputCapExceeded} />
|
||||
{isExecuting && <PanelLoadingScreen />}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IDropdownStyleProps, IDropdownStyles, IStyleFunctionOrObject } from "@fluentui/react";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export function chooseSourceContainerStyles(): IStyleFunctionOrObject<IDropdownStyleProps, IDropdownStyles> {
|
||||
return {
|
||||
title: { height: 27, lineHeight: 27 },
|
||||
dropdownItem: { fontSize: 12 },
|
||||
dropdownItemDisabled: { fontSize: 12 },
|
||||
dropdownItemSelected: { fontSize: 12 },
|
||||
};
|
||||
}
|
||||
|
||||
export function chooseSourceContainerStyle(): CSSProperties {
|
||||
return { width: 300, fontSize: 12 };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Checkbox, Icon, Link, Stack, Text } from "@fluentui/react";
|
||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import React from "react";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export interface AdvancedComponentProps {
|
||||
useHashV1: boolean;
|
||||
setUseHashV1: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
export const AdvancedComponent = (props: AdvancedComponentProps): JSX.Element => {
|
||||
const { useHashV1, setUseHashV1, setSubPartitionKeys } = props;
|
||||
|
||||
const useHashV1CheckboxOnChange = (isChecked: boolean): void => {
|
||||
setUseHashV1(isChecked);
|
||||
setSubPartitionKeys([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleSectionComponent
|
||||
title="Advanced"
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
TelemetryProcessor.traceOpen(Action.ExpandAddGlobalSecondaryIndexPaneAdvancedSection);
|
||||
scrollToSection("collapsibleAdvancedSectionContent");
|
||||
}}
|
||||
>
|
||||
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
|
||||
<Checkbox
|
||||
label="My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2)"
|
||||
checked={useHashV1}
|
||||
styles={{
|
||||
text: { fontSize: 12 },
|
||||
checkbox: { width: 12, height: 12 },
|
||||
label: { padding: 0, alignItems: "center", wordWrap: "break-word", whiteSpace: "break-spaces" },
|
||||
}}
|
||||
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => {
|
||||
useHashV1CheckboxOnChange(isChecked);
|
||||
}}
|
||||
/>
|
||||
<Text variant="small">
|
||||
<Icon iconName="InfoSolid" className="removeIcon" /> To ensure compatibility with older SDKs, the created
|
||||
container will use a legacy partitioning scheme that supports partition key values of size only up to 101
|
||||
bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "}
|
||||
<Link href="https://aka.ms/cosmos-large-pk" target="_blank">
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</CollapsibleSectionComponent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { DefaultButton, Link, Stack, Text } from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import {
|
||||
AnalyticalStorageContent,
|
||||
isSynapseLinkEnabled,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import React from "react";
|
||||
import { getCollectionName } from "Utils/APITypeUtils";
|
||||
|
||||
export interface AnalyticalStoreComponentProps {
|
||||
explorer: Explorer;
|
||||
enableAnalyticalStore: boolean;
|
||||
setEnableAnalyticalStore: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => {
|
||||
const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props;
|
||||
|
||||
const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => {
|
||||
if (checked && !enableAnalyticalStore) {
|
||||
setEnableAnalyticalStore(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisableAnalyticalStoreRadioButtonnChange = (checked: boolean): void => {
|
||||
if (checked && enableAnalyticalStore) {
|
||||
setEnableAnalyticalStore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{AnalyticalStorageContent()}
|
||||
</Text>
|
||||
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<div role="radiogroup">
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Enable analytical store"
|
||||
aria-checked={enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="enableAnalyticalStoreBtn"
|
||||
tabIndex={0}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onEnableAnalyticalStoreRadioButtonChange(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">On</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Disable analytical store"
|
||||
aria-checked={!enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="disableAnalyticalStoreBtn"
|
||||
tabIndex={0}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onDisableAnalyticalStoreRadioButtonnChange(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Off</span>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{!isSynapseLinkEnabled() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text variant="small">
|
||||
Azure Synapse Link is required for creating an analytical store {getCollectionName().toLocaleLowerCase()}.
|
||||
Enable Synapse Link for this Cosmos DB account.{" "}
|
||||
<Link
|
||||
href="https://aka.ms/cosmosdb-synapselink"
|
||||
target="_blank"
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
|
||||
className="capacitycalculator-link"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
<DefaultButton
|
||||
text="Enable"
|
||||
onClick={() => explorer.openEnableSynapseLinkDialog()}
|
||||
style={{ height: 27, width: 80 }}
|
||||
styles={{ label: { fontSize: 12 } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import { FullTextIndex, FullTextPolicy } from "Contracts/DataModels";
|
||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
|
||||
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import React from "react";
|
||||
|
||||
export interface FullTextSearchComponentProps {
|
||||
fullTextPolicy: FullTextPolicy;
|
||||
setFullTextPolicy: React.Dispatch<React.SetStateAction<FullTextPolicy>>;
|
||||
setFullTextIndexes: React.Dispatch<React.SetStateAction<FullTextIndex[]>>;
|
||||
setFullTextPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
export const FullTextSearchComponent = (props: FullTextSearchComponentProps): JSX.Element => {
|
||||
const { fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated } = props;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<CollapsibleSectionComponent
|
||||
title="Container Full Text Search Policy"
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
scrollToSection("collapsibleFullTextPolicySectionContent");
|
||||
}}
|
||||
>
|
||||
<Stack id="collapsibleFullTextPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
||||
<Stack styles={{ root: { paddingLeft: 40 } }}>
|
||||
<FullTextPoliciesComponent
|
||||
fullTextPolicy={fullTextPolicy}
|
||||
onFullTextPathChange={(
|
||||
fullTextPolicy: FullTextPolicy,
|
||||
fullTextIndexes: FullTextIndex[],
|
||||
fullTextPolicyValidated: boolean,
|
||||
) => {
|
||||
setFullTextPolicy(fullTextPolicy);
|
||||
setFullTextIndexes(fullTextIndexes);
|
||||
setFullTextPolicyValidated(fullTextPolicyValidated);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CollapsibleSectionComponent>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { DefaultButton, DirectionalHint, Icon, IconButton, Link, Stack, Text, TooltipHost } from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import {
|
||||
getPartitionKeyName,
|
||||
getPartitionKeyPlaceHolder,
|
||||
getPartitionKeyTooltipText,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import React from "react";
|
||||
|
||||
export interface PartitionKeyComponentProps {
|
||||
partitionKey?: string;
|
||||
setPartitionKey: React.Dispatch<React.SetStateAction<string>>;
|
||||
subPartitionKeys: string[];
|
||||
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
useHashV1: boolean;
|
||||
}
|
||||
|
||||
export const PartitionKeyComponent = (props: PartitionKeyComponentProps): JSX.Element => {
|
||||
const { partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 } = props;
|
||||
|
||||
const partitionKeyValueOnChange = (value: string): void => {
|
||||
if (!partitionKey && !value.startsWith("/")) {
|
||||
setPartitionKey("/" + value);
|
||||
} else {
|
||||
setPartitionKey(value);
|
||||
}
|
||||
};
|
||||
|
||||
const subPartitionKeysValueOnChange = (value: string, index: number): void => {
|
||||
const updatedSubPartitionKeys: string[] = [...subPartitionKeys];
|
||||
if (!updatedSubPartitionKeys[index] && !value.startsWith("/")) {
|
||||
updatedSubPartitionKeys[index] = "/" + value.trim();
|
||||
} else {
|
||||
updatedSubPartitionKeys[index] = value.trim();
|
||||
}
|
||||
setSubPartitionKeys(updatedSubPartitionKeys);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Partition key
|
||||
</Text>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={getPartitionKeyTooltipText()}>
|
||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="addGlobalSecondaryIndex-partitionKeyValue"
|
||||
aria-required
|
||||
required
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
placeholder={getPartitionKeyPlaceHolder()}
|
||||
aria-label={getPartitionKeyName()}
|
||||
pattern=".*"
|
||||
value={partitionKey}
|
||||
style={{ marginBottom: 8 }}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
partitionKeyValueOnChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{subPartitionKeys.map((subPartitionKey: string, subPartitionKeyIndex: number) => {
|
||||
return (
|
||||
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${subPartitionKeyIndex}`} horizontal>
|
||||
<div
|
||||
style={{
|
||||
width: "20px",
|
||||
border: "solid",
|
||||
borderWidth: "0px 0px 1px 1px",
|
||||
marginRight: "5px",
|
||||
}}
|
||||
></div>
|
||||
<input
|
||||
type="text"
|
||||
id="addGlobalSecondaryIndex-partitionKeyValue"
|
||||
key={`addGlobalSecondaryIndex-partitionKeyValue_${subPartitionKeyIndex}`}
|
||||
aria-required
|
||||
required
|
||||
size={40}
|
||||
tabIndex={subPartitionKeyIndex > 0 ? 1 : 0}
|
||||
className="panelTextField"
|
||||
autoComplete="off"
|
||||
placeholder={getPartitionKeyPlaceHolder(subPartitionKeyIndex)}
|
||||
aria-label={getPartitionKeyName()}
|
||||
pattern={".*"}
|
||||
title={""}
|
||||
value={subPartitionKey}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
subPartitionKeysValueOnChange(event.target.value, subPartitionKeyIndex);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Delete" }}
|
||||
style={{ height: 27 }}
|
||||
onClick={() => {
|
||||
const updatedSubPartitionKeys = subPartitionKeys.filter(
|
||||
(_, subPartitionKeyIndexToRemove) => subPartitionKeyIndex !== subPartitionKeyIndexToRemove,
|
||||
);
|
||||
setSubPartitionKeys(updatedSubPartitionKeys);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
<Stack className="panelGroupSpacing">
|
||||
<DefaultButton
|
||||
styles={{ root: { padding: 0, width: 200, height: 30 }, label: { fontSize: 12 } }}
|
||||
hidden={useHashV1}
|
||||
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
|
||||
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
|
||||
>
|
||||
Add hierarchical partition key
|
||||
</DefaultButton>
|
||||
{subPartitionKeys.length > 0 && (
|
||||
<Text variant="small">
|
||||
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to partition your
|
||||
data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview
|
||||
JavaScript V3 SDK.{" "}
|
||||
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Checkbox, Stack } from "@fluentui/react";
|
||||
import { ThroughputInput } from "Explorer/Controls/ThroughputInput/ThroughputInput";
|
||||
import { isFreeTierAccount } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import React from "react";
|
||||
import { getCollectionName } from "Utils/APITypeUtils";
|
||||
import { isServerlessAccount } from "Utils/CapabilityUtils";
|
||||
|
||||
export interface ThroughputComponentProps {
|
||||
enableDedicatedThroughput: boolean;
|
||||
setEnabledDedicatedThroughput: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isSelectedSourceContainerSharedThroughput: () => boolean;
|
||||
showCollectionThroughputInput: () => boolean;
|
||||
globalSecondaryIndexThroughputOnChange: (globalSecondaryIndexThroughputValue: number) => void;
|
||||
setIsThroughputCapExceeded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isCostAknowledgedOnChange: (isCostAknowledgedValue: boolean) => void;
|
||||
}
|
||||
|
||||
export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Element => {
|
||||
const {
|
||||
enableDedicatedThroughput,
|
||||
setEnabledDedicatedThroughput,
|
||||
isSelectedSourceContainerSharedThroughput,
|
||||
showCollectionThroughputInput,
|
||||
globalSecondaryIndexThroughputOnChange,
|
||||
setIsThroughputCapExceeded,
|
||||
isCostAknowledgedOnChange,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{!isServerlessAccount() && isSelectedSourceContainerSharedThroughput() && (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<Checkbox
|
||||
label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
|
||||
checked={enableDedicatedThroughput}
|
||||
styles={{
|
||||
text: { fontSize: 12 },
|
||||
checkbox: { width: 12, height: 12 },
|
||||
label: { padding: 0, alignItems: "center" },
|
||||
}}
|
||||
onChange={(_, isChecked: boolean) => setEnabledDedicatedThroughput(isChecked)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{showCollectionThroughputInput() && (
|
||||
<ThroughputInput
|
||||
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !useDatabases.getState().isFirstResourceCreated()}
|
||||
isDatabase={false}
|
||||
isSharded={false}
|
||||
isFreeTier={isFreeTierAccount()}
|
||||
isQuickstart={false}
|
||||
isGlobalSecondaryIndex={true}
|
||||
setThroughputValue={(throughput: number) => {
|
||||
globalSecondaryIndexThroughputOnChange(throughput);
|
||||
}}
|
||||
setIsAutoscale={() => {}}
|
||||
setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => {
|
||||
setIsThroughputCapExceeded(isThroughputCapExceeded);
|
||||
}}
|
||||
onCostAcknowledgeChange={(isAcknowledged: boolean) => {
|
||||
isCostAknowledgedOnChange(isAcknowledged);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
|
||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
|
||||
import {
|
||||
ContainerVectorPolicyTooltipContent,
|
||||
scrollToSection,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import React from "react";
|
||||
|
||||
export interface VectorSearchComponentProps {
|
||||
vectorEmbeddingPolicy: VectorEmbedding[];
|
||||
setVectorEmbeddingPolicy: React.Dispatch<React.SetStateAction<VectorEmbedding[]>>;
|
||||
vectorIndexingPolicy: VectorIndex[];
|
||||
setVectorIndexingPolicy: React.Dispatch<React.SetStateAction<VectorIndex[]>>;
|
||||
setVectorPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => {
|
||||
const {
|
||||
vectorEmbeddingPolicy,
|
||||
setVectorEmbeddingPolicy,
|
||||
vectorIndexingPolicy,
|
||||
setVectorIndexingPolicy,
|
||||
setVectorPolicyValidated,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<CollapsibleSectionComponent
|
||||
title="Container Vector Policy"
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
scrollToSection("collapsibleVectorPolicySectionContent");
|
||||
}}
|
||||
tooltipContent={ContainerVectorPolicyTooltipContent()}
|
||||
>
|
||||
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
||||
<Stack styles={{ root: { paddingLeft: 40 } }}>
|
||||
<VectorEmbeddingPoliciesComponent
|
||||
vectorEmbeddings={vectorEmbeddingPolicy}
|
||||
vectorIndexes={vectorIndexingPolicy}
|
||||
onVectorEmbeddingChange={(
|
||||
vectorEmbeddingPolicy: VectorEmbedding[],
|
||||
vectorIndexingPolicy: VectorIndex[],
|
||||
vectorPolicyValidated: boolean,
|
||||
) => {
|
||||
setVectorEmbeddingPolicy(vectorEmbeddingPolicy);
|
||||
setVectorIndexingPolicy(vectorIndexingPolicy);
|
||||
setVectorPolicyValidated(vectorPolicyValidated);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CollapsibleSectionComponent>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
|
||||
<form
|
||||
className="panelFormWrapper"
|
||||
id="panelGlobalSecondaryIndex"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="panelMainContent"
|
||||
>
|
||||
<Stack>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
>
|
||||
<span
|
||||
className="mandatoryStar"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
<Text
|
||||
className="panelTextBold"
|
||||
variant="small"
|
||||
>
|
||||
Source container id
|
||||
</Text>
|
||||
</Stack>
|
||||
<Dropdown
|
||||
onChange={[Function]}
|
||||
placeholder="Choose source container"
|
||||
style={
|
||||
{
|
||||
"fontSize": 12,
|
||||
"width": 300,
|
||||
}
|
||||
}
|
||||
styles={
|
||||
{
|
||||
"dropdownItem": {
|
||||
"fontSize": 12,
|
||||
},
|
||||
"dropdownItemDisabled": {
|
||||
"fontSize": 12,
|
||||
},
|
||||
"dropdownItemSelected": {
|
||||
"fontSize": 12,
|
||||
},
|
||||
"title": {
|
||||
"height": 27,
|
||||
"lineHeight": 27,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Separator
|
||||
className="panelSeparator"
|
||||
/>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
>
|
||||
<span
|
||||
className="mandatoryStar"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
<Text
|
||||
className="panelTextBold"
|
||||
variant="small"
|
||||
>
|
||||
Global secondary index container id
|
||||
</Text>
|
||||
</Stack>
|
||||
<input
|
||||
aria-required={true}
|
||||
autoComplete="off"
|
||||
className="panelTextField"
|
||||
id="globalSecondaryIndexId"
|
||||
onChange={[Function]}
|
||||
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
|
||||
placeholder="e.g., indexbyEmailId"
|
||||
required={true}
|
||||
size={40}
|
||||
title="May not end with space nor contain characters '\\' '/' '#' '?'"
|
||||
type="text"
|
||||
/>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
>
|
||||
<span
|
||||
className="mandatoryStar"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
<Text
|
||||
className="panelTextBold"
|
||||
variant="small"
|
||||
>
|
||||
Global secondary index definition
|
||||
</Text>
|
||||
<StyledTooltipHostBase
|
||||
content={
|
||||
<StyledLinkBase
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
|
||||
target="blank"
|
||||
>
|
||||
Learn more about defining global secondary indexes.
|
||||
</StyledLinkBase>
|
||||
}
|
||||
directionalHint={4}
|
||||
>
|
||||
<Icon
|
||||
className="panelInfoIcon"
|
||||
iconName="Info"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</StyledTooltipHostBase>
|
||||
</Stack>
|
||||
<input
|
||||
aria-required={true}
|
||||
autoComplete="off"
|
||||
className="panelTextField"
|
||||
id="globalSecondaryIndexDefinition"
|
||||
onChange={[Function]}
|
||||
placeholder="SELECT c.email, c.accountId FROM c"
|
||||
required={true}
|
||||
size={40}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<PartitionKeyComponent
|
||||
partitionKey=""
|
||||
setPartitionKey={[Function]}
|
||||
setSubPartitionKeys={[Function]}
|
||||
subPartitionKeys={[]}
|
||||
/>
|
||||
<ThroughputComponent
|
||||
globalSecondaryIndexThroughputOnChange={[Function]}
|
||||
isCostAknowledgedOnChange={[Function]}
|
||||
isSelectedSourceContainerSharedThroughput={[Function]}
|
||||
setEnabledDedicatedThroughput={[Function]}
|
||||
setIsThroughputCapExceeded={[Function]}
|
||||
showCollectionThroughputInput={[Function]}
|
||||
/>
|
||||
<AnalyticalStoreComponent
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"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],
|
||||
"copyNotebook": [Function],
|
||||
"parameters": [Function],
|
||||
},
|
||||
}
|
||||
}
|
||||
setEnableAnalyticalStore={[Function]}
|
||||
/>
|
||||
<AdvancedComponent
|
||||
setSubPartitionKeys={[Function]}
|
||||
setUseHashV1={[Function]}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
<PanelFooterComponent
|
||||
buttonLabel="OK"
|
||||
/>
|
||||
</form>
|
||||
`;
|
||||
@@ -7,6 +7,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "UserContext";
|
||||
import { isServerlessAccount } from "Utils/CapabilityUtils";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
|
||||
@@ -202,8 +203,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
required={true}
|
||||
autoComplete="off"
|
||||
styles={getTextFieldStyles()}
|
||||
pattern="[^/?#\\-]*[^/?#- \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder="Type a new keyspace id"
|
||||
size={40}
|
||||
value={newKeyspaceId}
|
||||
@@ -292,8 +293,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
required={true}
|
||||
ariaLabel="addCollection-table Id Create table"
|
||||
autoComplete="off"
|
||||
pattern="[^/?#\\-]*[^/?#- \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder="Enter table Id"
|
||||
size={20}
|
||||
value={tableId}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { userContext } from "UserContext";
|
||||
import { getCollectionName } from "Utils/APITypeUtils";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -235,8 +236,8 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., ${getCollectionName()}1`}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="3"
|
||||
value="4"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -148,7 +148,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="4"
|
||||
value="5"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -219,7 +219,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="5"
|
||||
value="6"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -281,7 +281,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="6"
|
||||
value="7"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -423,7 +423,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="7"
|
||||
value="8"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -459,7 +459,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="8"
|
||||
value="9"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -495,7 +495,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="9"
|
||||
value="10"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -575,7 +575,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
className="customAccordion ___1uf6361_0000000 fz7g6wx"
|
||||
>
|
||||
<AccordionItem
|
||||
value="6"
|
||||
value="7"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -717,7 +717,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="7"
|
||||
value="8"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
@@ -753,7 +753,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="11"
|
||||
value="12"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,7 @@ import { createCollection } from "Common/dataAccess/createCollection";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import { ContainerSampleGenerator } from "Explorer/DataSamples/ContainerSampleGenerator";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel";
|
||||
import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import { PromptCard } from "Explorer/QueryCopilot/PromptCard";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { useCarousel } from "hooks/useCarousel";
|
||||
|
||||
@@ -22,12 +22,17 @@ export const DeletePopup = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={showDeletePopup} styles={{ main: { minHeight: "122px", minWidth: "880px" } }}>
|
||||
<Modal
|
||||
isOpen={showDeletePopup}
|
||||
styles={{ main: { minHeight: "122px", minWidth: "880px" } }}
|
||||
titleAriaId="deleteDialogTitle"
|
||||
subtitleAriaId="deleteDialogSubTitle"
|
||||
>
|
||||
<Stack style={{ padding: "16px 24px", height: "auto" }}>
|
||||
<Text style={{ height: 24, fontSize: "18px" }}>
|
||||
<Text id="deleteDialogTitle" style={{ height: 24, fontSize: "18px" }}>
|
||||
<b>Delete code?</b>
|
||||
</Text>
|
||||
<Text style={{ marginTop: 10, marginBottom: 20 }}>
|
||||
<Text id="deleteDialogSubTitle" style={{ marginTop: 10, marginBottom: 20 }}>
|
||||
This will clear the query from the query builder pane along with all comments and also reset the prompt pane
|
||||
</Text>
|
||||
<Stack horizontal tokens={{ childrenGap: 10 }} horizontalAlign="start">
|
||||
|
||||
@@ -11,6 +11,8 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa
|
||||
},
|
||||
}
|
||||
}
|
||||
subtitleAriaId="deleteDialogSubTitle"
|
||||
titleAriaId="deleteDialogTitle"
|
||||
>
|
||||
<Stack
|
||||
style={
|
||||
@@ -21,6 +23,7 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa
|
||||
}
|
||||
>
|
||||
<Text
|
||||
id="deleteDialogTitle"
|
||||
style={
|
||||
{
|
||||
"fontSize": "18px",
|
||||
@@ -33,6 +36,7 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa
|
||||
</b>
|
||||
</Text>
|
||||
<Text
|
||||
id="deleteDialogSubTitle"
|
||||
style={
|
||||
{
|
||||
"marginBottom": 20,
|
||||
@@ -89,6 +93,8 @@ exports[`Delete Popup snapshot test should render when showDeletePopup is true 1
|
||||
},
|
||||
}
|
||||
}
|
||||
subtitleAriaId="deleteDialogSubTitle"
|
||||
titleAriaId="deleteDialogTitle"
|
||||
>
|
||||
<Stack
|
||||
style={
|
||||
@@ -99,6 +105,7 @@ exports[`Delete Popup snapshot test should render when showDeletePopup is true 1
|
||||
}
|
||||
>
|
||||
<Text
|
||||
id="deleteDialogTitle"
|
||||
style={
|
||||
{
|
||||
"fontSize": "18px",
|
||||
@@ -111,6 +118,7 @@ exports[`Delete Popup snapshot test should render when showDeletePopup is true 1
|
||||
</b>
|
||||
</Text>
|
||||
<Text
|
||||
id="deleteDialogSubTitle"
|
||||
style={
|
||||
{
|
||||
"marginBottom": 20,
|
||||
|
||||
@@ -13,15 +13,21 @@ import {
|
||||
SplitButton,
|
||||
} from "@fluentui/react-components";
|
||||
import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons";
|
||||
import { GlobalSecondaryIndexLabels } from "Common/Constants";
|
||||
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel";
|
||||
import {
|
||||
AddGlobalSecondaryIndexPanel,
|
||||
AddGlobalSecondaryIndexPanelProps,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
|
||||
import { Tabs } from "Explorer/Tabs/Tabs";
|
||||
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
||||
import { ResourceTree } from "Explorer/Tree/ResourceTree";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { KeyboardAction, KeyboardActionGroup, KeyboardActionHandler, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
import { isFabric, isFabricMirrored, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isFabric, isFabricMirrored, isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil";
|
||||
import { userContext } from "UserContext";
|
||||
import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
|
||||
import { Allotment, AllotmentHandle } from "allotment";
|
||||
@@ -162,6 +168,25 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (isGlobalSecondaryIndexEnabled()) {
|
||||
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
|
||||
explorer,
|
||||
};
|
||||
|
||||
actions.push({
|
||||
id: "new_materialized_view",
|
||||
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
|
||||
icon: <Add16Regular />,
|
||||
onClick: () =>
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
|
||||
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [explorer]);
|
||||
|
||||
@@ -293,6 +318,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
|
||||
const hasGlobalCommands = !(
|
||||
isFabricMirrored() ||
|
||||
isFabricNativeReadOnly() ||
|
||||
userContext.apiType === "Postgres" ||
|
||||
userContext.apiType === "VCoreMongo"
|
||||
);
|
||||
@@ -315,16 +341,18 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
<>
|
||||
<div className={styles.floatingControlsContainer}>
|
||||
<div className={styles.floatingControls}>
|
||||
<button
|
||||
type="button"
|
||||
data-test="Sidebar/RefreshButton"
|
||||
className={styles.floatingControlButton}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
onClick={onRefreshClick}
|
||||
>
|
||||
<ArrowSync12Regular />
|
||||
</button>
|
||||
{!isFabricNative() && (
|
||||
<button
|
||||
type="button"
|
||||
data-test="Sidebar/RefreshButton"
|
||||
className={styles.floatingControlButton}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
onClick={onRefreshClick}
|
||||
>
|
||||
<ArrowSync12Regular />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingControlButton}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
import { Link, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { DocumentAddRegular, LinkMultipleRegular } from "@fluentui/react-icons";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
|
||||
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||
import { isFabricNative, isFabricNativeReadOnly, openSettingsConnectionTab } from "Platform/Fabric/FabricUtil";
|
||||
import * as React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import CosmosDbBlackIcon from "../../../images/CosmosDB_black.svg";
|
||||
@@ -60,6 +62,15 @@ const useStyles = makeStyles({
|
||||
margin: "auto",
|
||||
},
|
||||
},
|
||||
single: {
|
||||
gridColumn: "1 / 4",
|
||||
gridRow: "1 / 3",
|
||||
"& svg": {
|
||||
width: "64px",
|
||||
height: "64px",
|
||||
margin: "auto",
|
||||
},
|
||||
},
|
||||
buttonContainer: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
@@ -108,12 +119,10 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
|
||||
onClick,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
|
||||
// TODO Make this a11y copmliant: aria-label for icon
|
||||
return (
|
||||
<div role="button" className={`${styles.buttonContainer} ${className}`} onClick={onClick}>
|
||||
<div className={styles.buttonUpperPart}>{icon}</div>
|
||||
<div className={styles.buttonLowerPart}>
|
||||
<div aria-label={title} className={styles.buttonLowerPart}>
|
||||
<div>{title}</div>
|
||||
<div>{description}</div>
|
||||
</div>
|
||||
@@ -123,6 +132,8 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
|
||||
|
||||
export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScreenProps) => {
|
||||
const styles = useStyles();
|
||||
const [openSampleDataImportDialog, setOpenSampleDataImportDialog] = React.useState(false);
|
||||
|
||||
const getSplashScreenButtons = (): JSX.Element => {
|
||||
const buttons: FabricHomeScreenButtonProps[] = [
|
||||
{
|
||||
@@ -138,15 +149,21 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
title: "Sample data",
|
||||
description: "Automatically load sample data in your database",
|
||||
icon: <img src={CosmosDbBlackIcon} />,
|
||||
onClick: () => setOpenSampleDataImportDialog(true),
|
||||
},
|
||||
{
|
||||
title: "App development",
|
||||
description: "Start here to use an SDK to build your apps",
|
||||
icon: <LinkMultipleRegular />,
|
||||
onClick: () => openSettingsConnectionTab(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
return isFabricNativeReadOnly() ? (
|
||||
<div className={styles.buttonsContainer}>
|
||||
<FabricHomeScreenButton className={styles.single} {...buttons[2]} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.buttonsContainer}>
|
||||
<FabricHomeScreenButton className={styles.one} {...buttons[0]} />
|
||||
<FabricHomeScreenButton className={styles.two} {...buttons[1]} />
|
||||
@@ -155,19 +172,27 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
);
|
||||
};
|
||||
|
||||
const title = "Build your database";
|
||||
const title = isFabricNativeReadOnly() ? "Use your database" : "Build your database";
|
||||
return (
|
||||
<div className={styles.homeContainer}>
|
||||
<div className={styles.title} role="heading" aria-label={title}>
|
||||
{title}
|
||||
</div>
|
||||
{getSplashScreenButtons()}
|
||||
<div className={styles.footer}>
|
||||
Need help?{" "}
|
||||
<Link href="https://cosmos.azure.com/docs" target="_blank">
|
||||
Learn more <img src={LinkIcon} alt="Learn more" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<CosmosFluentProvider className={styles.homeContainer}>
|
||||
<SampleDataImportDialog
|
||||
open={openSampleDataImportDialog}
|
||||
setOpen={setOpenSampleDataImportDialog}
|
||||
explorer={props.explorer}
|
||||
databaseName={userContext.fabricContext?.databaseName}
|
||||
/>
|
||||
<div className={styles.title} role="heading" aria-label={title}>
|
||||
{title}
|
||||
</div>
|
||||
{getSplashScreenButtons()}
|
||||
<div className={styles.footer}>
|
||||
Need help?{" "}
|
||||
<Link href="https://aka.ms/cosmosdbfabricdocs" target="_blank">
|
||||
Learn more <img src={LinkIcon} alt="Learn more" />
|
||||
</Link>
|
||||
</div>
|
||||
</CosmosFluentProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
158
src/Explorer/SplashScreen/SampleDataImportDialog.tsx
Normal file
158
src/Explorer/SplashScreen/SampleDataImportDialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
makeStyles,
|
||||
Spinner,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { checkContainerExists, createContainer, importData } from "Explorer/SplashScreen/SampleUtil";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
|
||||
const SAMPLE_DATA_CONTAINER_NAME = "SampleData";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
dialogContent: {
|
||||
alignItems: "center",
|
||||
marginBottom: tokens.spacingVerticalL,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This dialog:
|
||||
* - creates a container
|
||||
* - imports data into the container
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export const SampleDataImportDialog: React.FC<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
explorer: Explorer;
|
||||
databaseName: string;
|
||||
}> = (props) => {
|
||||
const [status, setStatus] = useState<"idle" | "creating" | "importing" | "completed" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const containerName = SAMPLE_DATA_CONTAINER_NAME;
|
||||
const [collection, setCollection] = useState<ViewModels.Collection>(undefined);
|
||||
const styles = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
// Reset state when dialog opens
|
||||
if (props.open) {
|
||||
setStatus("idle");
|
||||
setErrorMessage(undefined);
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
const handleStartImport = async (): Promise<void> => {
|
||||
setStatus("creating");
|
||||
const databaseName = props.databaseName;
|
||||
if (checkContainerExists(databaseName, containerName)) {
|
||||
const msg = `The container "${containerName}" in database "${databaseName}" already exists. Please delete it and retry.`;
|
||||
setStatus("error");
|
||||
setErrorMessage(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
let collection;
|
||||
try {
|
||||
collection = await createContainer(databaseName, containerName, props.explorer);
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(`Failed to create container: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus("importing");
|
||||
await importData(collection);
|
||||
setCollection(collection);
|
||||
setStatus("completed");
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(`Failed to import data: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionOnClick = () => {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
handleStartImport();
|
||||
break;
|
||||
case "error":
|
||||
props.setOpen(false);
|
||||
break;
|
||||
case "creating":
|
||||
case "importing":
|
||||
props.setOpen(false);
|
||||
break;
|
||||
case "completed":
|
||||
props.setOpen(false);
|
||||
collection.openTab();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return `Create a container "${containerName}" and import sample data into it. This may take a few minutes.`;
|
||||
|
||||
case "creating":
|
||||
return <Spinner size="small" labelPosition="above" label={`Creating container "${containerName}"...`} />;
|
||||
case "importing":
|
||||
return <Spinner size="small" labelPosition="above" label={`Importing data into "${containerName}"...`} />;
|
||||
case "completed":
|
||||
return `Successfully created "${containerName}" with sample data.`;
|
||||
case "error":
|
||||
return (
|
||||
<div style={{ color: "red" }}>
|
||||
<div>Error: {errorMessage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonLabel = () => {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return "Start";
|
||||
case "creating":
|
||||
case "importing":
|
||||
return "Close";
|
||||
case "completed":
|
||||
return "Close";
|
||||
case "error":
|
||||
return "Close";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(event, data) => props.setOpen(data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Sample Data</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className={styles.dialogContent}>{renderContent()}</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleActionOnClick}
|
||||
disabled={status === "creating" || status === "importing"}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
64
src/Explorer/SplashScreen/SampleUtil.ts
Normal file
64
src/Explorer/SplashScreen/SampleUtil.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { BackendDefaults } from "Common/Constants";
|
||||
import { createCollection } from "Common/dataAccess/createCollection";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
|
||||
/**
|
||||
* Public for unit tests
|
||||
* @param databaseName
|
||||
* @param containerName
|
||||
* @param containerDatabases
|
||||
*/
|
||||
const hasContainer = (
|
||||
databaseName: string,
|
||||
containerName: string,
|
||||
containerDatabases: ViewModels.Database[],
|
||||
): boolean => {
|
||||
const filteredDatabases = containerDatabases.filter((database) => database.id() === databaseName);
|
||||
return (
|
||||
filteredDatabases.length > 0 &&
|
||||
filteredDatabases[0].collections().filter((collection) => collection.id() === containerName).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
export const checkContainerExists = (databaseName: string, containerName: string) =>
|
||||
hasContainer(databaseName, containerName, useDatabases.getState().databases);
|
||||
|
||||
export const createContainer = async (
|
||||
databaseName: string,
|
||||
containerName: string,
|
||||
explorer: Explorer,
|
||||
): Promise<ViewModels.Collection> => {
|
||||
const createRequest: DataModels.CreateCollectionParams = {
|
||||
createNewDatabase: false,
|
||||
collectionId: containerName,
|
||||
databaseId: databaseName,
|
||||
databaseLevelThroughput: false,
|
||||
partitionKey: {
|
||||
paths: [`/${SAMPLE_DATA_PARTITION_KEY}`],
|
||||
kind: "Hash",
|
||||
version: BackendDefaults.partitionKeyVersion,
|
||||
},
|
||||
};
|
||||
await createCollection(createRequest);
|
||||
await explorer.refreshAllDatabases();
|
||||
const database = useDatabases.getState().findDatabaseWithId(databaseName);
|
||||
if (!database) {
|
||||
return undefined;
|
||||
}
|
||||
await database.loadCollections();
|
||||
const newCollection = database.findCollectionWithId(containerName);
|
||||
return newCollection;
|
||||
};
|
||||
|
||||
const SAMPLE_DATA_PARTITION_KEY = "category"; // This pkey is specifically set for queryCopilotSampleData.json below
|
||||
|
||||
export const importData = async (collection: ViewModels.Collection): Promise<void> => {
|
||||
// TODO: keep same chunk as ContainerSampleGenerator
|
||||
const dataFileContent = await import(
|
||||
/* webpackChunkName: "queryCopilotSampleData" */ "../../../sampleData/queryCopilotSampleData.json"
|
||||
);
|
||||
await collection.bulkInsertDocuments(dataFileContent.data);
|
||||
};
|
||||
@@ -817,7 +817,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
|
||||
|
||||
private vcoreMongoNextStepItems: { link: string; title: string; description: string }[] = [
|
||||
{
|
||||
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/how-to-migrate-native-tools?tabs=export-import",
|
||||
link: "https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options",
|
||||
title: "Migrate Data",
|
||||
description: "",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import "xterm/css/xterm.css";
|
||||
import { DatabaseAccount } from "../../../Contracts/DataModels";
|
||||
import { TerminalKind } from "../../../Contracts/ViewModels";
|
||||
import { startCloudShellTerminal } from "./CloudShellTerminalCore";
|
||||
|
||||
export interface CloudShellTerminalComponentProps {
|
||||
databaseAccount: DatabaseAccount;
|
||||
tabId: string;
|
||||
username?: string;
|
||||
shellType?: TerminalKind;
|
||||
}
|
||||
|
||||
export const CloudShellTerminalComponent: React.FC<CloudShellTerminalComponentProps> = (props) => {
|
||||
const terminalRef = useRef(null); // Reference for terminal container
|
||||
const xtermRef = useRef(null); // Reference for XTerm instance
|
||||
const socketRef = useRef(null); // Reference for WebSocket
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize XTerm instance
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#ffcc00",
|
||||
},
|
||||
scrollback: 1000,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
// Attach terminal to the DOM
|
||||
if (terminalRef.current) {
|
||||
terminal.open(terminalRef.current);
|
||||
xtermRef.current = terminal;
|
||||
}
|
||||
|
||||
// Defer terminal sizing until after DOM rendering is complete
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 0);
|
||||
|
||||
// Use ResizeObserver instead of window resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const container = terminalRef.current;
|
||||
if (container && container.offsetWidth > 0 && container.offsetHeight > 0) {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (e) {
|
||||
console.warn("Fit failed on resize:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
|
||||
socketRef.current = startCloudShellTerminal(terminal, props.shellType);
|
||||
|
||||
// Cleanup function to close WebSocket and dispose terminal
|
||||
return () => {
|
||||
if (!socketRef.current) {
|
||||
return;
|
||||
}
|
||||
if (socketRef.current && socketRef.current.readyState && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.close(); // Close WebSocket connection
|
||||
}
|
||||
if (resizeObserver && terminalRef.current) {
|
||||
resizeObserver.unobserve(terminalRef.current);
|
||||
}
|
||||
terminal.dispose(); // Clean up XTerm instance
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={terminalRef} style={{ width: "100%", height: "500px" }} />;
|
||||
};
|
||||
290
src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx
Normal file
290
src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { Areas } from "../../../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
|
||||
import { TerminalKind } from "../../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "../../../UserContext";
|
||||
import {
|
||||
connectTerminal,
|
||||
provisionConsole,
|
||||
putEphemeralUserSettings,
|
||||
registerCloudShellProvider,
|
||||
verifyCloudShellProviderRegistration,
|
||||
} from "./Data/CloudShellClient";
|
||||
import { CloudShellProviderInfo, ProvisionConsoleResponse } from "./Models/DataModels";
|
||||
import { AbstractShellHandler, START_MARKER } from "./ShellTypes/AbstractShellHandler";
|
||||
import { getHandler } from "./ShellTypes/ShellTypeFactory";
|
||||
import { AttachAddon } from "./Utils/AttachAddOn";
|
||||
import { askConfirmation, wait } from "./Utils/CommonUtils";
|
||||
import { getNormalizedRegion } from "./Utils/RegionUtils";
|
||||
import { formatErrorMessage, formatInfoMessage, formatWarningMessage } from "./Utils/TerminalLogFormats";
|
||||
|
||||
// Constants
|
||||
const DEFAULT_CLOUDSHELL_REGION = "westus";
|
||||
const POLLING_INTERVAL_MS = 2000;
|
||||
const MAX_RETRY_COUNT = 10;
|
||||
const MAX_PING_COUNT = 120 * 60; // 120 minutes (60 seconds/minute)
|
||||
|
||||
let pingCount = 0;
|
||||
let keepAliveID: NodeJS.Timeout = null;
|
||||
|
||||
/**
|
||||
* Main function to start a CloudShell terminal
|
||||
*/
|
||||
export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind): Promise<WebSocket> => {
|
||||
const startKey = TelemetryProcessor.traceStart(Action.CloudShellTerminalSession, {
|
||||
shellType: TerminalKind[shellType],
|
||||
dataExplorerArea: Areas.CloudShell,
|
||||
});
|
||||
|
||||
let resolvedRegion: string;
|
||||
try {
|
||||
await ensureCloudShellProviderRegistered();
|
||||
|
||||
resolvedRegion = determineCloudShellRegion();
|
||||
// Ask for user consent for region
|
||||
const consentGranted = await askConfirmation(
|
||||
terminal,
|
||||
formatWarningMessage(
|
||||
"The shell environment may be operating in a region different from that of the database, which could impact performance or data compliance. Do you wish to proceed?",
|
||||
),
|
||||
);
|
||||
|
||||
// Track user decision
|
||||
TelemetryProcessor.trace(
|
||||
Action.CloudShellUserConsent,
|
||||
consentGranted ? ActionModifiers.Success : ActionModifiers.Cancel,
|
||||
{ dataExplorerArea: Areas.CloudShell },
|
||||
);
|
||||
|
||||
if (!consentGranted) {
|
||||
TelemetryProcessor.traceCancel(
|
||||
Action.CloudShellTerminalSession,
|
||||
{
|
||||
shellType: TerminalKind[shellType],
|
||||
dataExplorerArea: Areas.CloudShell,
|
||||
region: resolvedRegion,
|
||||
isConsent: false,
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
terminal.writeln(
|
||||
formatErrorMessage("Session ended. Please close this tab and initiate a new shell session if needed."),
|
||||
);
|
||||
return null; // Exit if user declined
|
||||
}
|
||||
|
||||
terminal.writeln(formatInfoMessage("Connecting to CloudShell. This may take a moment. Please wait..."));
|
||||
|
||||
const sessionDetails: {
|
||||
socketUri?: string;
|
||||
provisionConsoleResponse?: ProvisionConsoleResponse;
|
||||
targetUri?: string;
|
||||
} = await provisionCloudShellSession(resolvedRegion, terminal);
|
||||
|
||||
if (!sessionDetails.socketUri) {
|
||||
terminal.writeln(formatErrorMessage("Failed to establish a connection. Please try again later."));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the shell handler for this type
|
||||
const shellHandler = await getHandler(shellType);
|
||||
// Configure WebSocket connection with shell-specific commands
|
||||
const socket = await establishTerminalConnection(terminal, shellHandler, sessionDetails.socketUri);
|
||||
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CloudShellTerminalSession,
|
||||
{
|
||||
shellType: TerminalKind[shellType],
|
||||
dataExplorerArea: Areas.CloudShell,
|
||||
region: resolvedRegion,
|
||||
socketUri: sessionDetails.socketUri,
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
|
||||
return socket;
|
||||
} catch (err) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CloudShellTerminalSession,
|
||||
{
|
||||
shellType: TerminalKind[shellType],
|
||||
dataExplorerArea: Areas.CloudShell,
|
||||
region: resolvedRegion,
|
||||
error: getErrorMessage(err),
|
||||
errorStack: getErrorStack(err),
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
|
||||
terminal.writeln(formatErrorMessage(`Failed with error.${getErrorMessage(err)}`));
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that the CloudShell provider is registered for the current subscription
|
||||
*/
|
||||
export const ensureCloudShellProviderRegistered = async (): Promise<void> => {
|
||||
const response: CloudShellProviderInfo = await verifyCloudShellProviderRegistration(userContext.subscriptionId);
|
||||
|
||||
if (response.registrationState !== "Registered") {
|
||||
await registerCloudShellProvider(userContext.subscriptionId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the appropriate CloudShell region
|
||||
*/
|
||||
export const determineCloudShellRegion = (): string => {
|
||||
return getNormalizedRegion(userContext.databaseAccount?.location, DEFAULT_CLOUDSHELL_REGION);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provisions a CloudShell session
|
||||
*/
|
||||
export const provisionCloudShellSession = async (
|
||||
resolvedRegion: string,
|
||||
terminal: Terminal,
|
||||
): Promise<{ socketUri?: string; provisionConsoleResponse?: ProvisionConsoleResponse; targetUri?: string }> => {
|
||||
// Apply user settings
|
||||
await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion);
|
||||
|
||||
// Provision console
|
||||
let provisionConsoleResponse;
|
||||
let attemptCounter = 0;
|
||||
|
||||
do {
|
||||
provisionConsoleResponse = await provisionConsole(resolvedRegion);
|
||||
attemptCounter++;
|
||||
|
||||
if (provisionConsoleResponse.properties.provisioningState === "Failed") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
|
||||
await wait(POLLING_INTERVAL_MS);
|
||||
}
|
||||
} while (provisionConsoleResponse.properties.provisioningState !== "Succeeded" && attemptCounter < MAX_RETRY_COUNT);
|
||||
|
||||
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
|
||||
throw new Error(`Provisioning failed: ${provisionConsoleResponse.properties.provisioningState}`);
|
||||
}
|
||||
|
||||
// Connect terminal
|
||||
const connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
});
|
||||
|
||||
const targetUri = `${provisionConsoleResponse.properties.uri}/terminals?cols=${terminal.cols}&rows=${terminal.rows}&version=2019-01-01&shell=bash`;
|
||||
const termId = connectTerminalResponse.id;
|
||||
|
||||
// Determine socket URI
|
||||
let socketUri = connectTerminalResponse.socketUri.replace(":443/", "");
|
||||
const targetUriBody = targetUri.replace("https://", "").split("?")[0];
|
||||
|
||||
// This socket URI transformation logic handles different Azure service endpoint formats.
|
||||
// If the returned socketUri doesn't contain the expected host, we construct it manually.
|
||||
// This ensures compatibility across different Azure regions and deployment configurations.
|
||||
if (socketUri.indexOf(targetUriBody) === -1) {
|
||||
socketUri = `wss://${targetUriBody}/${termId}`;
|
||||
}
|
||||
|
||||
// Special handling for ServiceBus-based endpoints which require a specific URI format
|
||||
// with the hierarchical connection ($hc) path segment for terminal connections
|
||||
if (targetUriBody.includes("servicebus")) {
|
||||
const targetUriBodyArr = targetUriBody.split("/");
|
||||
socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`;
|
||||
}
|
||||
|
||||
return { socketUri, provisionConsoleResponse, targetUri };
|
||||
};
|
||||
|
||||
/**
|
||||
* Establishes a terminal connection via WebSocket
|
||||
*/
|
||||
export const establishTerminalConnection = async (
|
||||
terminal: Terminal,
|
||||
shellHandler: AbstractShellHandler,
|
||||
socketUri: string,
|
||||
): Promise<WebSocket> => {
|
||||
let socket = new WebSocket(socketUri);
|
||||
|
||||
// Get shell-specific initial commands
|
||||
const initCommands = shellHandler.getInitialCommands();
|
||||
|
||||
// Configure the socket
|
||||
socket = await configureSocketConnection(socket, socketUri, terminal, initCommands, 0);
|
||||
|
||||
const options = {
|
||||
startMarker: START_MARKER,
|
||||
shellHandler: shellHandler,
|
||||
};
|
||||
|
||||
// Attach the terminal addon
|
||||
const attachAddon = new AttachAddon(socket, options);
|
||||
terminal.loadAddon(attachAddon);
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configures a WebSocket connection for the terminal
|
||||
*/
|
||||
export const configureSocketConnection = async (
|
||||
socket: WebSocket,
|
||||
uri: string,
|
||||
terminal: Terminal,
|
||||
initCommands: string,
|
||||
socketRetryCount: number,
|
||||
): Promise<WebSocket> => {
|
||||
sendTerminalStartupCommands(socket, initCommands);
|
||||
|
||||
socket.onerror = async () => {
|
||||
if (socketRetryCount < MAX_RETRY_COUNT && socket.readyState !== WebSocket.CLOSED) {
|
||||
await configureSocketConnection(socket, uri, terminal, initCommands, socketRetryCount + 1);
|
||||
} else {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (keepAliveID) {
|
||||
clearTimeout(keepAliveID);
|
||||
pingCount = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
export const sendTerminalStartupCommands = (socket: WebSocket, initCommands: string): void => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(initCommands);
|
||||
} else {
|
||||
socket.onopen = () => {
|
||||
socket.send(initCommands);
|
||||
|
||||
// ensures connections don't remain open indefinitely by implementing an automatic timeout after 20 minutes.
|
||||
const keepSocketAlive = (socket: WebSocket) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
if (pingCount >= MAX_PING_COUNT) {
|
||||
socket.close();
|
||||
} else {
|
||||
socket.send("");
|
||||
pingCount++;
|
||||
// The code uses a recursive setTimeout pattern rather than setInterval,
|
||||
// which ensures each new ping only happens after the previous one completes
|
||||
// and naturally stops if the socket closes.
|
||||
keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
keepSocketAlive(socket);
|
||||
};
|
||||
}
|
||||
};
|
||||
337
src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx
Normal file
337
src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { armRequest } from "../../../../Utils/arm/request";
|
||||
import { NetworkType, OsType, SessionType, ShellType } from "../Models/DataModels";
|
||||
import {
|
||||
connectTerminal,
|
||||
getUserSettings,
|
||||
provisionConsole,
|
||||
putEphemeralUserSettings,
|
||||
registerCloudShellProvider,
|
||||
verifyCloudShellProviderRegistration,
|
||||
} from "./CloudShellClient";
|
||||
|
||||
// Instead of redeclaring fetch, modify the global context
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
fetch: jest.Mock;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-namespace */
|
||||
|
||||
// Define mock endpoint
|
||||
const MOCK_ARM_ENDPOINT = "https://mock-management.azure.com";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("uuid", () => ({
|
||||
v4: jest.fn().mockReturnValue("mocked-uuid"),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../ConfigContext", () => ({
|
||||
configContext: {
|
||||
ARM_ENDPOINT: "https://mock-management.azure.com",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
userContext: {
|
||||
authorizationToken: "Bearer mock-token",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../../Utils/arm/request");
|
||||
|
||||
jest.mock("../Utils/CommonUtils", () => ({
|
||||
getLocale: jest.fn().mockReturnValue("en-US"),
|
||||
}));
|
||||
|
||||
// Properly mock fetch with correct typings
|
||||
const mockJsonPromise = jest.fn();
|
||||
global.fetch = jest.fn().mockImplementationOnce(() => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: mockJsonPromise,
|
||||
text: jest.fn().mockResolvedValue(""),
|
||||
headers: new Headers(),
|
||||
} as unknown as Promise<Response>;
|
||||
}) as jest.Mock;
|
||||
|
||||
describe("CloudShellClient", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockJsonPromise.mockClear();
|
||||
});
|
||||
|
||||
// Reset all mocks after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
if (global.fetch) {
|
||||
delete global.fetch;
|
||||
}
|
||||
});
|
||||
|
||||
describe("getUserSettings", () => {
|
||||
it("should call armRequest with correct parameters and return settings", async () => {
|
||||
const mockSettings = { properties: { preferredLocation: "eastus" } };
|
||||
(armRequest as jest.Mock).mockResolvedValueOnce(mockSettings);
|
||||
|
||||
const result = await getUserSettings();
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "GET",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
});
|
||||
expect(result).toEqual(mockSettings);
|
||||
});
|
||||
|
||||
it("should handle errors when settings retrieval fails", async () => {
|
||||
const mockError = new Error("Failed to get user settings");
|
||||
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(getUserSettings()).rejects.toThrow("Failed to get user settings");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "GET",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("putEphemeralUserSettings", () => {
|
||||
it("should call armRequest with default network settings", async () => {
|
||||
const mockResponse = { id: "settings-id" };
|
||||
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await putEphemeralUserSettings("sub-id", "eastus");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
body: {
|
||||
properties: {
|
||||
preferredOsType: OsType.Linux,
|
||||
preferredShellType: ShellType.Bash,
|
||||
preferredLocation: "eastus",
|
||||
networkType: NetworkType.Default,
|
||||
sessionType: SessionType.Ephemeral,
|
||||
userSubscription: "sub-id",
|
||||
vnetSettings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should call armRequest with isolated network settings", async () => {
|
||||
const mockVNetSettings = { subnetId: "test-subnet" };
|
||||
const mockResponse = { id: "settings-id" };
|
||||
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await putEphemeralUserSettings("sub-id", "eastus", mockVNetSettings);
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
body: {
|
||||
properties: {
|
||||
preferredOsType: OsType.Linux,
|
||||
preferredShellType: ShellType.Bash,
|
||||
preferredLocation: "eastus",
|
||||
networkType: NetworkType.Isolated,
|
||||
sessionType: SessionType.Ephemeral,
|
||||
userSubscription: "sub-id",
|
||||
vnetSettings: mockVNetSettings,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors when updating settings fails", async () => {
|
||||
const mockError = new Error("Failed to update user settings");
|
||||
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(putEphemeralUserSettings("sub-id", "eastus")).rejects.toThrow("Failed to update user settings");
|
||||
|
||||
expect(armRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyCloudShellProviderRegistration", () => {
|
||||
it("should call armRequest with correct parameters", async () => {
|
||||
const mockResponse = { registrationState: "Registered" };
|
||||
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await verifyCloudShellProviderRegistration("sub-id");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell",
|
||||
method: "GET",
|
||||
apiVersion: "2022-12-01",
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle errors when verification fails", async () => {
|
||||
const mockError = new Error("Failed to verify provider registration");
|
||||
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(verifyCloudShellProviderRegistration("sub-id")).rejects.toThrow(
|
||||
"Failed to verify provider registration",
|
||||
);
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell",
|
||||
method: "GET",
|
||||
apiVersion: "2022-12-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerCloudShellProvider", () => {
|
||||
it("should call armRequest with correct parameters", async () => {
|
||||
const mockResponse = { operationId: "op-id" };
|
||||
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await registerCloudShellProvider("sub-id");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell/register",
|
||||
method: "POST",
|
||||
apiVersion: "2022-12-01",
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle errors when registration fails", async () => {
|
||||
const mockError = new Error("Failed to register provider");
|
||||
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(registerCloudShellProvider("sub-id")).rejects.toThrow("Failed to register provider");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell/register",
|
||||
method: "POST",
|
||||
apiVersion: "2022-12-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionConsole", () => {
|
||||
it("should call armRequest with correct parameters", async () => {
|
||||
const mockResponse = { uri: "https://shell.azure.com/console123" };
|
||||
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await provisionConsole("eastus");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "providers/Microsoft.Portal/consoles/default",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
customHeaders: {
|
||||
"x-ms-console-preferred-location": "eastus",
|
||||
},
|
||||
body: {
|
||||
properties: {
|
||||
osType: OsType.Linux,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle errors when console provisioning fails", async () => {
|
||||
const mockError = new Error("Failed to provision console");
|
||||
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(provisionConsole("eastus")).rejects.toThrow("Failed to provision console");
|
||||
|
||||
expect(armRequest).toHaveBeenCalledWith({
|
||||
host: MOCK_ARM_ENDPOINT,
|
||||
path: "providers/Microsoft.Portal/consoles/default",
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
customHeaders: {
|
||||
"x-ms-console-preferred-location": "eastus",
|
||||
},
|
||||
body: {
|
||||
properties: {
|
||||
osType: OsType.Linux,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectTerminal", () => {
|
||||
it("should call fetch with correct parameters", async () => {
|
||||
const consoleUri = "https://shell.azure.com/console123";
|
||||
const size = { rows: 24, cols: 80 };
|
||||
const mockTerminalResponse = { id: "terminal-id", socketUri: "wss://shell.azure.com/socket" };
|
||||
|
||||
// Setup the mock response
|
||||
mockJsonPromise.mockResolvedValueOnce(mockTerminalResponse);
|
||||
|
||||
const result = await connectTerminal(consoleUri, size);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://shell.azure.com/console123/terminals?cols=80&rows=24&version=2019-01-01&shell=bash",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": "2",
|
||||
Authorization: "Bearer mock-token",
|
||||
"x-ms-client-request-id": "mocked-uuid",
|
||||
"Accept-Language": "en-US",
|
||||
},
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
expect(mockJsonPromise).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockTerminalResponse);
|
||||
});
|
||||
|
||||
it("should handle errors when terminal connection fails", async () => {
|
||||
const consoleUri = "https://shell.azure.com/console123";
|
||||
const size = { rows: 24, cols: 80 };
|
||||
|
||||
// Mock fetch to return a failed response
|
||||
global.fetch = jest.fn().mockImplementationOnce(() => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
json: jest.fn().mockRejectedValue(new Error("Failed to parse JSON")),
|
||||
text: jest.fn().mockResolvedValue("Server Error"),
|
||||
headers: new Headers(),
|
||||
} as unknown as Promise<Response>;
|
||||
});
|
||||
|
||||
await expect(connectTerminal(consoleUri, size)).rejects.toThrow(
|
||||
"Failed to connect to terminal: 500 Internal Server Error",
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://shell.azure.com/console123/terminals?cols=80&rows=24&version=2019-01-01&shell=bash",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
117
src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx
Normal file
117
src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { configContext } from "../../../../ConfigContext";
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { armRequest } from "../../../../Utils/arm/request";
|
||||
import {
|
||||
CloudShellProviderInfo,
|
||||
CloudShellSettings,
|
||||
ConnectTerminalResponse,
|
||||
NetworkType,
|
||||
OsType,
|
||||
ProvisionConsoleResponse,
|
||||
SessionType,
|
||||
ShellType,
|
||||
} from "../Models/DataModels";
|
||||
import { getLocale } from "../Utils/CommonUtils";
|
||||
|
||||
export const getUserSettings = async (): Promise<CloudShellSettings> => {
|
||||
return await armRequest<CloudShellSettings>({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
|
||||
method: "GET",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
});
|
||||
};
|
||||
|
||||
export const putEphemeralUserSettings = async (
|
||||
userSubscriptionId: string,
|
||||
userRegion: string,
|
||||
vNetSettings?: object,
|
||||
) => {
|
||||
const ephemeralSettings: CloudShellSettings = {
|
||||
properties: {
|
||||
preferredOsType: OsType.Linux,
|
||||
preferredShellType: ShellType.Bash,
|
||||
preferredLocation: userRegion,
|
||||
networkType:
|
||||
!vNetSettings || Object.keys(vNetSettings).length === 0
|
||||
? NetworkType.Default
|
||||
: vNetSettings
|
||||
? NetworkType.Isolated
|
||||
: NetworkType.Default,
|
||||
sessionType: SessionType.Ephemeral,
|
||||
userSubscription: userSubscriptionId,
|
||||
vnetSettings: vNetSettings ?? {},
|
||||
},
|
||||
};
|
||||
|
||||
return await armRequest({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
body: ephemeralSettings,
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyCloudShellProviderRegistration = async (subscriptionId: string): Promise<CloudShellProviderInfo> => {
|
||||
return await armRequest({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell`,
|
||||
method: "GET",
|
||||
apiVersion: "2022-12-01",
|
||||
});
|
||||
};
|
||||
|
||||
export const registerCloudShellProvider = async (subscriptionId: string) => {
|
||||
return await armRequest({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell/register`,
|
||||
method: "POST",
|
||||
apiVersion: "2022-12-01",
|
||||
});
|
||||
};
|
||||
|
||||
export const provisionConsole = async (consoleLocation: string): Promise<ProvisionConsoleResponse> => {
|
||||
const data = {
|
||||
properties: {
|
||||
osType: OsType.Linux,
|
||||
},
|
||||
};
|
||||
|
||||
return await armRequest<ProvisionConsoleResponse>({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: `providers/Microsoft.Portal/consoles/default`,
|
||||
method: "PUT",
|
||||
apiVersion: "2023-02-01-preview",
|
||||
customHeaders: {
|
||||
"x-ms-console-preferred-location": consoleLocation,
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
export const connectTerminal = async (
|
||||
consoleUri: string,
|
||||
size: { rows: number; cols: number },
|
||||
): Promise<ConnectTerminalResponse> => {
|
||||
const targetUri = consoleUri + `/terminals?cols=${size.cols}&rows=${size.rows}&version=2019-01-01&shell=bash`;
|
||||
const resp = await fetch(targetUri, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": "2",
|
||||
Authorization: userContext.authorizationToken,
|
||||
"x-ms-client-request-id": uuidv4(),
|
||||
"Accept-Language": getLocale(),
|
||||
},
|
||||
body: "{}", // empty body is necessary
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to connect to terminal: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
};
|
||||
91
src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx
Normal file
91
src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
export const enum OsType {
|
||||
Linux = "linux",
|
||||
Windows = "windows",
|
||||
}
|
||||
|
||||
export const enum ShellType {
|
||||
Bash = "bash",
|
||||
PowerShellCore = "pwsh",
|
||||
}
|
||||
|
||||
export const enum NetworkType {
|
||||
Default = "Default",
|
||||
Isolated = "Isolated",
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure CloudShell session types:
|
||||
* - Mounted: Sessions with persistent storage via an Azure File Share mount.
|
||||
* Files and configurations are preserved between sessions, allowing for
|
||||
* continuity of work across multiple CloudShell sessions.
|
||||
*
|
||||
* - Ephemeral: Temporary sessions without persistent storage.
|
||||
* All files and changes are discarded when the session ends.
|
||||
* These sessions start faster but don't retain user data.
|
||||
*
|
||||
* The session type affects resource allocation, startup time,
|
||||
* and whether user files/configurations persist between sessions.
|
||||
*/
|
||||
export const enum SessionType {
|
||||
Mounted = "Mounted",
|
||||
Ephemeral = "Ephemeral",
|
||||
}
|
||||
|
||||
export type CloudShellSettings = {
|
||||
properties: UserSettingProperties;
|
||||
};
|
||||
|
||||
export type UserSettingProperties = {
|
||||
networkType: string;
|
||||
preferredLocation: string;
|
||||
preferredOsType: OsType;
|
||||
preferredShellType: ShellType;
|
||||
userSubscription: string;
|
||||
sessionType: SessionType;
|
||||
vnetSettings: object;
|
||||
};
|
||||
|
||||
export type ProvisionConsoleResponse = {
|
||||
properties: {
|
||||
osType: OsType;
|
||||
provisioningState: string;
|
||||
uri: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Authorization = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type ConnectTerminalResponse = {
|
||||
id: string;
|
||||
idleTimeout: string;
|
||||
rootDirectory: string;
|
||||
socketUri: string;
|
||||
tokenUpdated: boolean;
|
||||
};
|
||||
|
||||
export type ProviderAuthorization = {
|
||||
applicationId: string;
|
||||
roleDefinitionId: string;
|
||||
};
|
||||
|
||||
export type ProviderResourceType = {
|
||||
resourceType: string;
|
||||
locations: string[];
|
||||
apiVersions: string[];
|
||||
defaultApiVersion?: string;
|
||||
capabilities?: string;
|
||||
};
|
||||
|
||||
export type RegistrationState = "Registered" | "NotRegistered" | "Registering" | "Unregistering";
|
||||
export type RegistrationPolicy = "RegistrationRequired" | "RegistrationOptional";
|
||||
|
||||
export type CloudShellProviderInfo = {
|
||||
id: string;
|
||||
namespace: string;
|
||||
authorizations?: ProviderAuthorization[];
|
||||
resourceTypes: ProviderResourceType[];
|
||||
registrationState: RegistrationState;
|
||||
registrationPolicy: RegistrationPolicy;
|
||||
};
|
||||
282
src/Explorer/Tabs/CloudShellTab/README.md
Normal file
282
src/Explorer/Tabs/CloudShellTab/README.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Migrate Mongo(RU/vCore)/Postgres/Cassandra shell to CloudShell Design
|
||||
|
||||
## CloudShell Overview
|
||||
Cloud Shell provides an integrated terminal experience directly within Cosmos Explorer, allowing users to interact with different database engines using their native command-line interfaces.
|
||||
|
||||
## Component Architecture
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
|
||||
class FeatureRegistration {
|
||||
<<Registers a new flag for switching shell to CloudShell>>
|
||||
+enableCloudShell: boolean
|
||||
}
|
||||
|
||||
class ShellTypeHandlerFactory {
|
||||
<<Initialize corresponding handler based on the type of shell>>
|
||||
+getHandler(terminalKind: TerminalKind): ShellTypeHandler
|
||||
+getKey(): string
|
||||
}
|
||||
|
||||
class AbstractShellHandler {
|
||||
<<interface>>
|
||||
+getShellName(): string
|
||||
+getSetUpCommands(): string[]
|
||||
+getConnectionCommand(): string
|
||||
+getEndpoint(): string
|
||||
+getTerminalSuppressedData(): string[]
|
||||
+getInitialCommands(): string
|
||||
}
|
||||
|
||||
class CloudShellTerminalComponent {
|
||||
<<React Component to Render CloudShell>>
|
||||
-terminalKind: TerminalKind
|
||||
-shellHandler: AbstractShellHandler
|
||||
+render(): ReactElement
|
||||
}
|
||||
|
||||
class CloudShellTerminalCore {
|
||||
<<Initialize CloudShell>>
|
||||
+startCloudShellTerminal()
|
||||
}
|
||||
|
||||
class CloudShellClient {
|
||||
<Initialize CloudShell APIs>
|
||||
+getUserSettings(): Promise
|
||||
+putEphemeralUserSettings(): void
|
||||
+verifyCloudShellProviderRegistration: void
|
||||
+registerCloudShellProvider(): void
|
||||
+provisionConsole(): ProvisionConsoleResponse
|
||||
+connectTerminal(): ConnectTerminalResponse
|
||||
+authorizeSession(): Authorization
|
||||
}
|
||||
|
||||
class CloudShellTerminalComponentAdapter {
|
||||
+getDatabaseAccount: DataModels.DatabaseAccount,
|
||||
+getTabId: string,
|
||||
+getUsername: string,
|
||||
+isAllPublicIPAddressesEnabled: ko.Observable<boolean>,
|
||||
+kind: ViewModels.TerminalKind,
|
||||
}
|
||||
|
||||
class TerminalTab {
|
||||
-cloudShellTerminalComponentAdapter: CloudShellTerminalComponentAdapter
|
||||
}
|
||||
|
||||
class ContextMenuButtonFactory {
|
||||
+getCloudShellButton(): ReactElement
|
||||
+isCloudShellEnabled(): boolean
|
||||
}
|
||||
|
||||
UserContext --> FeatureRegistration : contains
|
||||
FeatureRegistration ..> ContextMenuButtonFactory : controls UI visibility
|
||||
FeatureRegistration ..> CloudShellTerminalComponentAdapter : enables tab creation
|
||||
FeatureRegistration ..> CloudShellClient : permits API calls
|
||||
|
||||
TerminalTab --> CloudShellTerminalComponentAdapter : manages
|
||||
ContextMenuButtonFactory --> TerminalTab : creates
|
||||
TerminalTab --> CloudShellTerminalComponent : renders
|
||||
CloudShellTerminalComponent --> CloudShellTerminalCore : contains
|
||||
CloudShellTerminalComponent --> ShellTypeHandlerFactory : uses
|
||||
CloudShellTerminalCore --> CloudShellClient : communicates with
|
||||
CloudShellTerminalCore --> AbstractShellHandler : uses configuration from
|
||||
|
||||
ShellTypeHandlerFactory --> AbstractShellHandler : creates
|
||||
|
||||
class MongoShellHandler {
|
||||
-key: string
|
||||
+getShellName(): string
|
||||
+getSetUpCommands(): string[]
|
||||
+getConnectionCommand(): string
|
||||
+getEndpoint(): string
|
||||
+getTerminalSuppressedData(): string[]
|
||||
+getInitialCommands(): string
|
||||
|
||||
class VCoreMongoShellHandler {
|
||||
+getShellName(): string
|
||||
+getSetUpCommands(): string[]
|
||||
+getConnectionCommand(): string
|
||||
+getEndpoint(): string
|
||||
+getTerminalSuppressedData(): string[]
|
||||
+getInitialCommands(): string
|
||||
}
|
||||
|
||||
class CassandraShellHandler {
|
||||
-key: string
|
||||
+getShellName(): string
|
||||
+getSetUpCommands(): string[]
|
||||
+getConnectionCommand(): string
|
||||
+getEndpoint(): string
|
||||
+getTerminalSuppressedData(): string[]
|
||||
+getInitialCommands(): string
|
||||
}
|
||||
|
||||
class PostgresShellHandler {
|
||||
+getShellName(): string
|
||||
+getSetUpCommands(): string[]
|
||||
+getConnectionCommand(): string
|
||||
+getEndpoint(): string
|
||||
+getTerminalSuppressedData(): string[]
|
||||
+getInitialCommands(): string
|
||||
}
|
||||
|
||||
AbstractShellHandler <|.. MongoShellHandler
|
||||
AbstractShellHandler <|.. VCoreMongoShellHandler
|
||||
AbstractShellHandler <|.. CassandraShellHandler
|
||||
AbstractShellHandler <|.. PostgresShellHandler
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
The CloudShell functionality is controlled by the feature flag `userContext.features.enableCloudShell`. When this flag is **enabled** (set to true), the following occurs in the application:
|
||||
|
||||
1. **UI Components Become Available:** There is "Open Mongo Shell" or similar button appears on data explorer or quick start window.
|
||||
|
||||
2. **Service Capabilities Are Activated:**
|
||||
- Backend API calls to CloudShell services are permitted
|
||||
- Terminal connection endpoints become accessible
|
||||
|
||||
3. **Database-Specific Features Are Unlocked:**
|
||||
- Terminal experiences tailored to each database type become available
|
||||
- Shell handlers are instantiated based on the database type
|
||||
|
||||
4. **Telemetry Collection Begins:**
|
||||
- When CloudShell Starts
|
||||
- User Consent to access shell out of the region
|
||||
- When shell is connected
|
||||
- When there is an error during CloudShell initialization
|
||||
|
||||
The feature can be enabled by putting `feature.enableCloudShell=true` in url.
|
||||
When disabled, all CloudShell functionality is hidden and inaccessible, ensuring a consistent user experience regardless of the feature's state. These shell would be talking to tools federation.
|
||||
|
||||
## Supported Shell Types
|
||||
|
||||
| Terminal Kind | Handler Class | Description |
|
||||
|---------------|--------------|-------------|
|
||||
| Mongo | MongoShellHandler | Handles MongoDB RU shell connections |
|
||||
| VCoreMongo | VCoreMongoShellHandler | Handles for VCore MongoDB shell connections |
|
||||
| Cassandra | CassandraShellHandler | Handles Cassandra shell connections |
|
||||
| Postgres | PostgresShellHandler | Handles PostgreSQL shell connections |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The CloudShell implementation uses the Factory pattern to create appropriate shell handlers based on the database type. Each handler implements the common interface but provides specialized behavior for connecting to different database engines.
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **ShellTypeHandlerFactory**: Creates the appropriate handler based on terminal kind
|
||||
- Retrieves authentication keys from Azure Resource Manager
|
||||
- Instantiates specialized handlers with configuration
|
||||
|
||||
2. **ShellTypeHandler Interface i.e. AbstractShellHandler**: Defines the contract for all shell handlers
|
||||
- `getConnectionCommand()`: Returns shell command to connect to database
|
||||
- `getSetUpCommands()`: Returns list of scripts required to set up the environment
|
||||
- `getEndpoint()`: Returns database connection end point
|
||||
- `getTerminalSuppressedData()`: Returns a string which needs to be suppressed
|
||||
|
||||
3. **Specialized Handlers**: Implement specific connection logic for each database type
|
||||
- Handle authentication differences
|
||||
- Provide appropriate shell arguments
|
||||
- Format connection strings correctly
|
||||
|
||||
4. **CloudShellTerminalComponent**: React component that renders the terminal interface
|
||||
- Receives the terminal type as a property
|
||||
- Uses ShellTypeHandlerFactory to get the appropriate handler
|
||||
- Renders the CloudShellTerminalCore with the handler's configuration
|
||||
- Manages component lifecycle and state
|
||||
|
||||
5. **CloudShellTerminalCore**: Core terminal implementation
|
||||
- Handles low-level terminal operations
|
||||
- Uses the configuration from ShellTypeHandler to initialize the terminal
|
||||
- Manages input/output streams between the user interface and the shell process
|
||||
- Handles terminal events (resize, data, etc.)
|
||||
- Implements terminal UI and styling
|
||||
|
||||
6. **CloudShellClient**: Client for interacting with CloudShell backend services
|
||||
- Initializes the terminal session with backend services
|
||||
- Manages communication between the terminal UI and the backend shell process
|
||||
- Handles authentication and security for the terminal session
|
||||
|
||||
7. **ContextMenuButtonFactory**: Creates CloudShell UI entry points
|
||||
- Checks if CloudShell is enabled via `userContext.features.enableCloudShell`
|
||||
- Generates appropriate terminal buttons based on database type
|
||||
- Handles conditional rendering of CloudShell options
|
||||
|
||||
8. **TerminalTab**: Container component for terminal experiences
|
||||
- Renders appropriate terminal type based on the selected database
|
||||
- Manages terminal tab state and lifecycle
|
||||
- Provides the integration point between the terminal and the rest of the Cosmos Explorer UI
|
||||
|
||||
## Telemetry Collection
|
||||
|
||||
CloudShell components utilize `TelemetryProcessor.trace` to collect usage data and diagnostics information that help improve the service and troubleshoot issues.
|
||||
|
||||
### Telemetry Events
|
||||
- When CloudShell Starts
|
||||
- User Consent to access shell out of the region
|
||||
- When shell is connected
|
||||
- When there is an error during CloudShell initialization
|
||||
|
||||
| Action Name | Description | Collected Data |
|
||||
|------------|------------|----------------|
|
||||
| CloudShellTerminalSession/Start | Triggered when user starts a CloudShell session | Shell Type, dataExplorerArea as <i>CloudShell</i>|
|
||||
| CloudShellUserConsent/(Success/Failure) | Records user consent to get cloudshell in other region | |
|
||||
| CloudShellTerminalSession/Success | Records if Terminal creation is successful | Shell Type, Shell Region |
|
||||
| CloudShellTerminalSession/Failure | Records of terminal creation is failed | Shell Type, Shell region (if available), error message |
|
||||
|
||||
### Real-time Use Cases
|
||||
|
||||
1. **Performance Monitoring**:
|
||||
- Track shell initialization times across different regions and database types
|
||||
|
||||
2. **Error Detection and Resolution**:
|
||||
- Detect increased error rates in real-time
|
||||
- Identify patterns in failures
|
||||
- Correlate errors with specific client configurations
|
||||
|
||||
3. **Feature Adoption Analysis**:
|
||||
- Measure adoption rates of different terminal types
|
||||
|
||||
4. **User Experience Optimization**:
|
||||
- Analyze session duration to understand engagement
|
||||
- Identify abandoned sessions and potential pain points
|
||||
- Measure the impact of new features on usage patterns
|
||||
- Track command completion rates and error recovery
|
||||
|
||||
## Limitations and Regional Availability
|
||||
|
||||
### Network Isolation
|
||||
|
||||
Network isolation (such as private endpoints, service endpoints, and VNet integration) is not currently supported for CloudShell connections. All connections to database instances through CloudShell require the database to be accessible through public endpoints.
|
||||
|
||||
Key limitations:
|
||||
- Cannot connect to databases with public network access disabled
|
||||
- No support for private link resources
|
||||
- No integration with Azure Virtual Networks
|
||||
- IP-based firewall rules must include CloudShell service IPs
|
||||
|
||||
### Data Residency
|
||||
|
||||
Data residency requirements may not be fully satisfied when using CloudShell due to limited regional availability. CloudShell services are currently available in the following regions:
|
||||
|
||||
| Geography | Regions |
|
||||
|-----------|---------|
|
||||
| Americas | East US, West US 2, South Central US, West Central US |
|
||||
| Europe | West Europe, North Europe |
|
||||
| Asia Pacific | Southeast Asia, Japan East, Australia East |
|
||||
| Middle East | UAE North |
|
||||
|
||||
**Note:** For up-to-date supported regions, refer to the region configuration in:
|
||||
`src/Explorer/CloudShell/Configuration/RegionConfig.ts`
|
||||
|
||||
### Implications for Compliance
|
||||
|
||||
Organizations with strict data residency or network isolation requirements should be aware of these limitations:
|
||||
|
||||
1. Data may transit through regions different from the database region
|
||||
2. Terminal session data is processed in CloudShell regions, not necessarily the database region
|
||||
3. Commands and queries are executed through CloudShell services, not directly against the database
|
||||
4. Connection strings contain database endpoints and are processed by CloudShell services
|
||||
|
||||
These limitations are important considerations for workloads with specific compliance or regulatory requirements.
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AbstractShellHandler, DISABLE_HISTORY, START_MARKER, EXIT_COMMAND } from "./AbstractShellHandler";
|
||||
|
||||
// Mock implementation for testing
|
||||
class MockShellHandler extends AbstractShellHandler {
|
||||
getShellName(): string {
|
||||
return "MockShell";
|
||||
}
|
||||
|
||||
getSetUpCommands(): string[] {
|
||||
return ["setup-command-1", "setup-command-2"];
|
||||
}
|
||||
|
||||
getConnectionCommand(): string {
|
||||
return "mock-connection-command";
|
||||
}
|
||||
|
||||
getEndpoint(): string {
|
||||
return "mock-endpoint";
|
||||
}
|
||||
|
||||
getTerminalSuppressedData(): string {
|
||||
return "suppressed-data";
|
||||
}
|
||||
}
|
||||
|
||||
describe("AbstractShellHandler", () => {
|
||||
let shellHandler: MockShellHandler;
|
||||
|
||||
// Reset all mocks and spies before each test
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
shellHandler = new MockShellHandler();
|
||||
});
|
||||
|
||||
// Reset everything after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getInitialCommands", () => {
|
||||
it("should combine commands in the correct order", () => {
|
||||
// Spy on abstract methods to ensure they're called
|
||||
const getSetUpCommandsSpy = jest.spyOn(shellHandler, "getSetUpCommands");
|
||||
const getConnectionCommandSpy = jest.spyOn(shellHandler, "getConnectionCommand");
|
||||
|
||||
const result = shellHandler.getInitialCommands();
|
||||
|
||||
// Verify abstract methods were called
|
||||
expect(getSetUpCommandsSpy).toHaveBeenCalled();
|
||||
expect(getConnectionCommandSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify output format and content
|
||||
const expectedOutput = [
|
||||
START_MARKER,
|
||||
DISABLE_HISTORY,
|
||||
"setup-command-1",
|
||||
"setup-command-2",
|
||||
`{ mock-connection-command; } || true;${EXIT_COMMAND}`,
|
||||
]
|
||||
.join("\n")
|
||||
.concat("\n");
|
||||
|
||||
expect(result).toBe(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("abstract methods implementation", () => {
|
||||
it("should return the correct shell name", () => {
|
||||
expect(shellHandler.getShellName()).toBe("MockShell");
|
||||
});
|
||||
|
||||
it("should return the setup commands", () => {
|
||||
expect(shellHandler.getSetUpCommands()).toEqual(["setup-command-1", "setup-command-2"]);
|
||||
});
|
||||
|
||||
it("should return the connection command", () => {
|
||||
expect(shellHandler.getConnectionCommand()).toBe("mock-connection-command");
|
||||
});
|
||||
|
||||
it("should return the endpoint", () => {
|
||||
expect(shellHandler.getEndpoint()).toBe("mock-endpoint");
|
||||
});
|
||||
|
||||
it("should return the terminal suppressed data", () => {
|
||||
expect(shellHandler.getTerminalSuppressedData()).toBe("suppressed-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Command that serves as a marker to indicate the start of shell initialization.
|
||||
* Outputs to /dev/null to prevent displaying in the terminal.
|
||||
*/
|
||||
export const START_MARKER = `echo "START INITIALIZATION" > /dev/null`;
|
||||
|
||||
/**
|
||||
* Command to disable command history recording in the shell.
|
||||
* Prevents initialization commands from appearing in history.
|
||||
*/
|
||||
export const DISABLE_HISTORY = `set +o history`;
|
||||
/**
|
||||
* Command that displays an error message and exits the shell session.
|
||||
* Used when shell initialization or connection fails.
|
||||
*/
|
||||
export const EXIT_COMMAND = ` printf "\\033[1;31mSession ended. Please close this tab and initiate a new shell session if needed.\\033[0m\\n" && exit`;
|
||||
|
||||
/**
|
||||
* Abstract class that defines the interface for shell-specific handlers
|
||||
* in the CloudShell terminal implementation. Each supported shell type
|
||||
* (Mongo, PG, etc.) should extend this class and implement
|
||||
* the required methods.
|
||||
*/
|
||||
export abstract class AbstractShellHandler {
|
||||
abstract getShellName(): string;
|
||||
abstract getSetUpCommands(): string[];
|
||||
abstract getConnectionCommand(): string;
|
||||
abstract getTerminalSuppressedData(): string;
|
||||
|
||||
/**
|
||||
* Constructs the complete initialization command sequence for the shell.
|
||||
*
|
||||
* This method:
|
||||
* 1. Starts with the initialization marker
|
||||
* 2. Disables command history
|
||||
* 3. Adds shell-specific setup commands
|
||||
* 4. Adds the connection command with error handling
|
||||
* 5. Adds a fallback exit command if connection fails
|
||||
*
|
||||
* The connection command is wrapped in a construct that prevents
|
||||
* errors from terminating the entire session immediately, allowing
|
||||
* the friendly exit message to be displayed.
|
||||
*
|
||||
* @returns {string} Complete initialization command sequence with newlines
|
||||
*/
|
||||
public getInitialCommands(): string {
|
||||
const setupCommands = this.getSetUpCommands();
|
||||
const connectionCommand = this.getConnectionCommand();
|
||||
|
||||
const allCommands = [
|
||||
START_MARKER,
|
||||
DISABLE_HISTORY,
|
||||
...setupCommands,
|
||||
`{ ${connectionCommand}; } || true;${EXIT_COMMAND}`,
|
||||
];
|
||||
|
||||
return allCommands.join("\n").concat("\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as CommonUtils from "../Utils/CommonUtils";
|
||||
import { CassandraShellHandler } from "./CassandraShellHandler";
|
||||
|
||||
// Define interfaces for the database account structure
|
||||
interface DatabaseAccountProperties {
|
||||
cassandraEndpoint?: string;
|
||||
}
|
||||
|
||||
interface DatabaseAccount {
|
||||
name?: string;
|
||||
properties?: DatabaseAccountProperties;
|
||||
}
|
||||
|
||||
// Define mock state that can be modified by tests
|
||||
const mockState = {
|
||||
databaseAccount: {
|
||||
name: "test-account",
|
||||
properties: {
|
||||
cassandraEndpoint: "https://test-endpoint.cassandra.cosmos.azure.com:443/",
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
};
|
||||
|
||||
// Mock dependencies using factory functions
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
get userContext() {
|
||||
return {
|
||||
get databaseAccount() {
|
||||
return mockState.databaseAccount;
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Reset all modules before running tests
|
||||
beforeAll(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
jest.mock("../Utils/CommonUtils", () => ({
|
||||
getHostFromUrl: jest.fn().mockReturnValue("test-endpoint.cassandra.cosmos.azure.com"),
|
||||
}));
|
||||
|
||||
describe("CassandraShellHandler", () => {
|
||||
const testKey = "test-key";
|
||||
let handler: CassandraShellHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handler = new CassandraShellHandler(testKey);
|
||||
|
||||
// Reset mock state before each test
|
||||
mockState.databaseAccount = {
|
||||
name: "test-account",
|
||||
properties: {
|
||||
cassandraEndpoint: "https://test-endpoint.cassandra.cosmos.azure.com:443/",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("Positive test cases", () => {
|
||||
test("should return 'Cassandra' as shell name", () => {
|
||||
expect(handler.getShellName()).toBe("Cassandra");
|
||||
});
|
||||
|
||||
test("should return an array of setup commands", () => {
|
||||
const commands = handler.getSetUpCommands();
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBe(5);
|
||||
expect(commands).toContain("source ~/.bashrc");
|
||||
expect(
|
||||
commands.some((cmd) =>
|
||||
cmd.includes("if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(commands.some((cmd) => cmd.includes("pip3 install --user cqlsh==6.2.0"))).toBe(true);
|
||||
expect(commands.some((cmd) => cmd.includes("export SSL_VERSION=TLSv1_2"))).toBe(true);
|
||||
expect(commands.some((cmd) => cmd.includes("export SSL_VALIDATE=false"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should return correct connection command", () => {
|
||||
const expectedCommand = "cqlsh test-endpoint.cassandra.cosmos.azure.com 10350 -u test-account -p test-key --ssl";
|
||||
|
||||
expect(handler.getConnectionCommand()).toBe(expectedCommand);
|
||||
expect(CommonUtils.getHostFromUrl).toHaveBeenCalledWith("https://test-endpoint.cassandra.cosmos.azure.com:443/");
|
||||
});
|
||||
|
||||
test("should return the correct terminal suppressed data", () => {
|
||||
expect(handler.getTerminalSuppressedData()).toBe("");
|
||||
});
|
||||
|
||||
test("should include the correct package version in setup commands", () => {
|
||||
const commands = handler.getSetUpCommands();
|
||||
const hasCorrectPackageVersion = commands.some((cmd) => cmd.includes("cqlsh==6.2.0"));
|
||||
|
||||
expect(hasCorrectPackageVersion).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative test cases", () => {
|
||||
test("should handle empty host from URL", () => {
|
||||
(CommonUtils.getHostFromUrl as jest.Mock).mockReturnValueOnce("");
|
||||
|
||||
const command = handler.getConnectionCommand();
|
||||
|
||||
expect(command).toBe("cqlsh 10350 -u test-account -p test-key --ssl");
|
||||
});
|
||||
|
||||
test("should handle empty key", () => {
|
||||
const emptyKeyHandler = new CassandraShellHandler("");
|
||||
|
||||
expect(emptyKeyHandler.getConnectionCommand()).toBe(
|
||||
"cqlsh test-endpoint.cassandra.cosmos.azure.com 10350 -u test-account -p --ssl",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle undefined account name", () => {
|
||||
mockState.databaseAccount = {
|
||||
properties: { cassandraEndpoint: "https://test-endpoint.cassandra.cosmos.azure.com:443/" },
|
||||
};
|
||||
|
||||
expect(handler.getConnectionCommand()).toBe("echo 'Database name not found.'");
|
||||
});
|
||||
|
||||
test("should handle undefined database account", () => {
|
||||
mockState.databaseAccount = undefined;
|
||||
|
||||
expect(handler.getConnectionCommand()).toBe("echo 'Database name not found.'");
|
||||
});
|
||||
|
||||
test("should handle missing cassandra endpoint", () => {
|
||||
mockState.databaseAccount = {
|
||||
name: "test-account",
|
||||
properties: {},
|
||||
};
|
||||
|
||||
expect(handler.getConnectionCommand()).toBe("echo 'Cassandra endpoint not found.'");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { getHostFromUrl } from "../Utils/CommonUtils";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
|
||||
const PACKAGE_VERSION: string = "6.2.0";
|
||||
|
||||
export class CassandraShellHandler extends AbstractShellHandler {
|
||||
private _key: string;
|
||||
private _endpoint: string | undefined;
|
||||
|
||||
constructor(private key: string) {
|
||||
super();
|
||||
this._key = key;
|
||||
this._endpoint = userContext?.databaseAccount?.properties?.cassandraEndpoint;
|
||||
}
|
||||
|
||||
public getShellName(): string {
|
||||
return "Cassandra";
|
||||
}
|
||||
|
||||
public getSetUpCommands(): string[] {
|
||||
return [
|
||||
"if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi",
|
||||
`if ! command -v cqlsh &> /dev/null; then pip3 install --user cqlsh==${PACKAGE_VERSION} ; fi`,
|
||||
"echo 'export SSL_VERSION=TLSv1_2' >> ~/.bashrc",
|
||||
"echo 'export SSL_VALIDATE=false' >> ~/.bashrc",
|
||||
"source ~/.bashrc",
|
||||
];
|
||||
}
|
||||
|
||||
public getConnectionCommand(): string {
|
||||
if (!this._endpoint) {
|
||||
return `echo '${this.getShellName()} endpoint not found.'`;
|
||||
}
|
||||
|
||||
const dbName = userContext?.databaseAccount?.name;
|
||||
if (!dbName) {
|
||||
return "echo 'Database name not found.'";
|
||||
}
|
||||
|
||||
return `cqlsh ${getHostFromUrl(this._endpoint)} 10350 -u ${dbName} -p ${this._key} --ssl`;
|
||||
}
|
||||
|
||||
public getTerminalSuppressedData(): string {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import * as CommonUtils from "../Utils/CommonUtils";
|
||||
import { MongoShellHandler } from "./MongoShellHandler";
|
||||
|
||||
// Define interfaces for type safety
|
||||
interface DatabaseAccountProperties {
|
||||
mongoEndpoint?: string;
|
||||
}
|
||||
|
||||
interface DatabaseAccount {
|
||||
id?: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
kind?: string;
|
||||
properties: DatabaseAccountProperties;
|
||||
}
|
||||
|
||||
interface UserContextType {
|
||||
databaseAccount: DatabaseAccount;
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
userContext: {
|
||||
databaseAccount: {
|
||||
name: "test-account",
|
||||
properties: {
|
||||
mongoEndpoint: "https://test-mongo.documents.azure.com:443/",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../Utils/CommonUtils", () => ({
|
||||
getHostFromUrl: jest.fn().mockReturnValue("test-mongo.documents.azure.com"),
|
||||
}));
|
||||
|
||||
describe("MongoShellHandler", () => {
|
||||
const testKey = "test-key";
|
||||
let mongoShellHandler: MongoShellHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mongoShellHandler = new MongoShellHandler(testKey);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("getShellName", () => {
|
||||
it("should return MongoDB", () => {
|
||||
expect(mongoShellHandler.getShellName()).toBe("MongoDB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSetUpCommands", () => {
|
||||
it("should return an array of setup commands", () => {
|
||||
const commands = mongoShellHandler.getSetUpCommands();
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBe(6);
|
||||
expect(commands[1]).toContain("mongosh-2.5.0-linux-x64.tgz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnectionCommand", () => {
|
||||
it("should return the correct connection command", () => {
|
||||
// Save original databaseAccount
|
||||
const originalDatabaseAccount = userContext.databaseAccount;
|
||||
|
||||
// Directly assign the modified databaseAccount
|
||||
(userContext as UserContextType).databaseAccount = {
|
||||
id: "test-id",
|
||||
name: "test-account",
|
||||
location: "test-location",
|
||||
type: "test-type",
|
||||
kind: "test-kind",
|
||||
properties: { mongoEndpoint: "https://test-mongo.documents.azure.com:443/" },
|
||||
};
|
||||
|
||||
const command = mongoShellHandler.getConnectionCommand();
|
||||
|
||||
expect(command).toBe(
|
||||
"mongosh --host test-mongo.documents.azure.com --port 10255 --username test-account --password test-key --tls --tlsAllowInvalidCertificates",
|
||||
);
|
||||
expect(CommonUtils.getHostFromUrl).toHaveBeenCalledWith("https://test-mongo.documents.azure.com:443/");
|
||||
|
||||
// Restore original
|
||||
(userContext as UserContextType).databaseAccount = originalDatabaseAccount;
|
||||
});
|
||||
|
||||
it("should handle missing database account name", () => {
|
||||
// Save original databaseAccount
|
||||
const originalDatabaseAccount = userContext.databaseAccount;
|
||||
|
||||
// Directly assign the modified databaseAccount
|
||||
(userContext as UserContextType).databaseAccount = {
|
||||
id: "test-id",
|
||||
name: "", // Empty name to simulate missing name
|
||||
location: "test-location",
|
||||
type: "test-type",
|
||||
kind: "test-kind",
|
||||
properties: { mongoEndpoint: "https://test.com" },
|
||||
};
|
||||
|
||||
const command = mongoShellHandler.getConnectionCommand();
|
||||
|
||||
expect(command).toBe("echo 'Database name not found.'");
|
||||
|
||||
// Restore original
|
||||
(userContext as UserContextType).databaseAccount = originalDatabaseAccount;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTerminalSuppressedData", () => {
|
||||
it("should return the correct warning message", () => {
|
||||
expect(mongoShellHandler.getTerminalSuppressedData()).toBe("Warning: Non-Genuine MongoDB Detected");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { getHostFromUrl } from "../Utils/CommonUtils";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
|
||||
const PACKAGE_VERSION: string = "2.5.0";
|
||||
|
||||
export class MongoShellHandler extends AbstractShellHandler {
|
||||
private _key: string;
|
||||
private _endpoint: string | undefined;
|
||||
constructor(private key: string) {
|
||||
super();
|
||||
this._key = key;
|
||||
this._endpoint = userContext?.databaseAccount?.properties?.mongoEndpoint;
|
||||
}
|
||||
|
||||
public getShellName(): string {
|
||||
return "MongoDB";
|
||||
}
|
||||
|
||||
public getSetUpCommands(): string[] {
|
||||
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 && mv mongosh-${PACKAGE_VERSION}-linux-x64/* ~/mongosh/; fi`,
|
||||
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
|
||||
"source ~/.bashrc",
|
||||
];
|
||||
}
|
||||
|
||||
public getConnectionCommand(): string {
|
||||
if (!this._endpoint) {
|
||||
return `echo '${this.getShellName()} endpoint not found.'`;
|
||||
}
|
||||
|
||||
const dbName = userContext?.databaseAccount?.name;
|
||||
if (!dbName) {
|
||||
return "echo 'Database name not found.'";
|
||||
}
|
||||
return `mongosh --host ${getHostFromUrl(this._endpoint)} --port 10255 --username ${dbName} --password ${
|
||||
this._key
|
||||
} --tls --tlsAllowInvalidCertificates`;
|
||||
}
|
||||
|
||||
public getTerminalSuppressedData(): string {
|
||||
return "Warning: Non-Genuine MongoDB Detected";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PostgresShellHandler } from "./PostgresShellHandler";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
userContext: {
|
||||
databaseAccount: {
|
||||
properties: {
|
||||
postgresqlEndpoint: "test-postgres.postgres.database.azure.com",
|
||||
},
|
||||
},
|
||||
postgresConnectionStrParams: {
|
||||
adminLogin: "test-admin",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PostgresShellHandler", () => {
|
||||
let postgresShellHandler: PostgresShellHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
postgresShellHandler = new PostgresShellHandler();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
// Positive test cases
|
||||
describe("Positive Tests", () => {
|
||||
it("should return correct shell name", () => {
|
||||
expect(postgresShellHandler.getShellName()).toBe("PostgreSQL");
|
||||
});
|
||||
|
||||
it("should return array of setup commands with correct package version", () => {
|
||||
const commands = postgresShellHandler.getSetUpCommands();
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBe(9);
|
||||
expect(commands[1]).toContain("postgresql-15.2.tar.bz2");
|
||||
expect(commands[0]).toContain("psql not found");
|
||||
});
|
||||
|
||||
it("should generate proper connection command with endpoint", () => {
|
||||
const connectionCommand = postgresShellHandler.getConnectionCommand();
|
||||
|
||||
expect(connectionCommand).toContain('-h "test-postgres.postgres.database.azure.com"');
|
||||
expect(connectionCommand).toContain("-p 5432");
|
||||
expect(connectionCommand).toContain("--set=sslmode=require");
|
||||
});
|
||||
|
||||
it("should return empty string for terminal suppressed data", () => {
|
||||
expect(postgresShellHandler.getTerminalSuppressedData()).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
|
||||
const PACKAGE_VERSION: string = "15.2";
|
||||
|
||||
export class PostgresShellHandler extends AbstractShellHandler {
|
||||
private _endpoint: string | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._endpoint = userContext?.databaseAccount?.properties?.postgresqlEndpoint;
|
||||
}
|
||||
|
||||
public getShellName(): string {
|
||||
return "PostgreSQL";
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL setup commands for CloudShell:
|
||||
*
|
||||
* 1. Check if psql client is already installed
|
||||
* 2. Download PostgreSQL source package if needed
|
||||
* 3. Extract the PostgreSQL package
|
||||
* 4. Create installation directory
|
||||
* 5. Download and extract readline dependency
|
||||
* 6. Configure readline with appropriate installation path
|
||||
* 7. Add PostgreSQL binaries to system PATH
|
||||
* 8. Apply PATH changes
|
||||
*
|
||||
* All installation steps run conditionally only if
|
||||
* psql is not already available in the environment.
|
||||
*/
|
||||
public getSetUpCommands(): string[] {
|
||||
return [
|
||||
"if ! command -v psql &> /dev/null; then echo '⚠️ psql not found. Installing...'; fi",
|
||||
`if ! command -v psql &> /dev/null; then curl -LO https://ftp.postgresql.org/pub/source/v${PACKAGE_VERSION}/postgresql-${PACKAGE_VERSION}.tar.bz2; fi`,
|
||||
`if ! command -v psql &> /dev/null; then tar -xvjf postgresql-${PACKAGE_VERSION}.tar.bz2; fi`,
|
||||
"if ! command -v psql &> /dev/null; then mkdir -p ~/pgsql; fi",
|
||||
"if ! command -v psql &> /dev/null; then curl -LO https://ftp.gnu.org/gnu/readline/readline-8.1.tar.gz; fi",
|
||||
"if ! command -v psql &> /dev/null; then tar -xvzf readline-8.1.tar.gz; fi",
|
||||
"if ! command -v psql &> /dev/null; then cd readline-8.1 && ./configure --prefix=$HOME/pgsql; fi",
|
||||
"if ! command -v psql &> /dev/null; then echo 'export PATH=$HOME/pgsql/bin:$PATH' >> ~/.bashrc; fi",
|
||||
"source ~/.bashrc",
|
||||
];
|
||||
}
|
||||
|
||||
public getConnectionCommand(): string {
|
||||
if (!this._endpoint) {
|
||||
return `echo '${this.getShellName()} endpoint not found.'`;
|
||||
}
|
||||
|
||||
// Database name is hardcoded as "citus" because Azure Cosmos DB for PostgreSQL
|
||||
// uses Citus as its distributed database extension with this default database name.
|
||||
// All Azure Cosmos DB PostgreSQL deployments follow this convention.
|
||||
// Ref. https://learn.microsoft.com/en-us/azure/cosmos-db/postgresql/reference-limits#database-creation
|
||||
const loginName = userContext.postgresConnectionStrParams.adminLogin;
|
||||
return `psql -h "${this._endpoint}" -p 5432 -d "citus" -U "${loginName}" --set=sslmode=require`;
|
||||
}
|
||||
|
||||
public getTerminalSuppressedData(): string {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TerminalKind } from "../../../../Contracts/ViewModels";
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { CassandraShellHandler } from "./CassandraShellHandler";
|
||||
import { MongoShellHandler } from "./MongoShellHandler";
|
||||
import { PostgresShellHandler } from "./PostgresShellHandler";
|
||||
import { getHandler, getKey } from "./ShellTypeFactory";
|
||||
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
userContext: {
|
||||
databaseAccount: { name: "testDbName" },
|
||||
subscriptionId: "testSubId",
|
||||
resourceGroup: "testResourceGroup",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
|
||||
listKeys: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("ShellTypeHandlerFactory", () => {
|
||||
const mockKey = "testKey";
|
||||
|
||||
beforeEach(() => {
|
||||
(listKeys as jest.Mock).mockResolvedValue({ primaryMasterKey: mockKey });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
// Negative test cases
|
||||
describe("Negative test cases", () => {
|
||||
it("should throw an error for unsupported terminal kind", async () => {
|
||||
await expect(getHandler("UnsupportedKind" as unknown as TerminalKind)).rejects.toThrow(
|
||||
"Unsupported shell type: UnsupportedKind",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty string when database name is missing", async () => {
|
||||
// Temporarily modify the mock
|
||||
const originalName = userContext.databaseAccount.name;
|
||||
type DatabaseAccountType = { name: string };
|
||||
(userContext.databaseAccount as DatabaseAccountType).name = "";
|
||||
|
||||
const key = await getKey();
|
||||
expect(key).toBe("");
|
||||
expect(listKeys).not.toHaveBeenCalled();
|
||||
|
||||
// Restore the mock
|
||||
(userContext.databaseAccount as DatabaseAccountType).name = originalName;
|
||||
});
|
||||
|
||||
it("should return empty string when listKeys returns null", async () => {
|
||||
(listKeys as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const key = await getKey();
|
||||
expect(key).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string when primaryMasterKey is missing", async () => {
|
||||
(listKeys as jest.Mock).mockResolvedValue({
|
||||
/* no primaryMasterKey */
|
||||
});
|
||||
|
||||
const key = await getKey();
|
||||
expect(key).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// Positive test cases
|
||||
describe("Positive test cases", () => {
|
||||
it("should return PostgresShellHandler for Postgres terminal kind", async () => {
|
||||
const handler = await getHandler(TerminalKind.Postgres);
|
||||
expect(handler).toBeInstanceOf(PostgresShellHandler);
|
||||
});
|
||||
|
||||
it("should return MongoShellHandler with key for Mongo terminal kind", async () => {
|
||||
const handler = await getHandler(TerminalKind.Mongo);
|
||||
expect(handler).toBeInstanceOf(MongoShellHandler);
|
||||
});
|
||||
|
||||
it("should return VCoreMongoShellHandler for VCoreMongo terminal kind", async () => {
|
||||
const handler = await getHandler(TerminalKind.VCoreMongo);
|
||||
expect(handler).toBeInstanceOf(VCoreMongoShellHandler);
|
||||
});
|
||||
|
||||
it("should return CassandraShellHandler with key for Cassandra terminal kind", async () => {
|
||||
const handler = await getHandler(TerminalKind.Cassandra);
|
||||
expect(handler).toBeInstanceOf(CassandraShellHandler);
|
||||
});
|
||||
|
||||
it("should get key successfully when database name exists", async () => {
|
||||
const key = await getKey();
|
||||
expect(key).toBe(mockKey);
|
||||
expect(listKeys).toHaveBeenCalledWith("testSubId", "testResourceGroup", "testDbName");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TerminalKind } from "../../../../Contracts/ViewModels";
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
import { CassandraShellHandler } from "./CassandraShellHandler";
|
||||
import { MongoShellHandler } from "./MongoShellHandler";
|
||||
import { PostgresShellHandler } from "./PostgresShellHandler";
|
||||
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
|
||||
|
||||
/**
|
||||
* Gets the appropriate handler for the given shell type
|
||||
*/
|
||||
export async function getHandler(shellType: TerminalKind): Promise<AbstractShellHandler> {
|
||||
switch (shellType) {
|
||||
case TerminalKind.Postgres:
|
||||
return new PostgresShellHandler();
|
||||
case TerminalKind.Mongo:
|
||||
return new MongoShellHandler(await getKey());
|
||||
case TerminalKind.VCoreMongo:
|
||||
return new VCoreMongoShellHandler();
|
||||
case TerminalKind.Cassandra:
|
||||
return new CassandraShellHandler(await getKey());
|
||||
default:
|
||||
throw new Error(`Unsupported shell type: ${shellType}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getKey(): Promise<string> {
|
||||
const dbName = userContext.databaseAccount.name;
|
||||
if (!dbName) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
|
||||
return keys?.primaryMasterKey || "";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
userContext: {
|
||||
databaseAccount: {
|
||||
properties: {
|
||||
vcoreMongoEndpoint: "test-vcore-mongo.mongo.cosmos.azure.com",
|
||||
},
|
||||
},
|
||||
vcoreMongoConnectionParams: {
|
||||
adminLogin: "username",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("VCoreMongoShellHandler", () => {
|
||||
let vcoreMongoShellHandler: VCoreMongoShellHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
vcoreMongoShellHandler = new VCoreMongoShellHandler();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
// Positive test cases
|
||||
describe("Positive Tests", () => {
|
||||
it("should return correct shell name", () => {
|
||||
expect(vcoreMongoShellHandler.getShellName()).toBe("MongoDB VCore");
|
||||
});
|
||||
|
||||
it("should return array of setup commands with correct package version", () => {
|
||||
const commands = vcoreMongoShellHandler.getSetUpCommands();
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBe(6);
|
||||
expect(commands[1]).toContain("mongosh-2.5.0-linux-x64.tgz");
|
||||
expect(commands[0]).toContain("mongosh not found");
|
||||
});
|
||||
|
||||
it("should generate proper connection command with endpoint", () => {
|
||||
const connectionCommand = vcoreMongoShellHandler.getConnectionCommand();
|
||||
|
||||
expect(connectionCommand).toContain("mongodb+srv://username:@test-vcore-mongo.mongo.cosmos.azure.com");
|
||||
expect(connectionCommand).toContain("authMechanism=SCRAM-SHA-256");
|
||||
});
|
||||
|
||||
it("should return the correct terminal suppressed data", () => {
|
||||
expect(vcoreMongoShellHandler.getTerminalSuppressedData()).toBe("Warning: Non-Genuine MongoDB Detected");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
|
||||
const PACKAGE_VERSION: string = "2.5.0";
|
||||
|
||||
export class VCoreMongoShellHandler extends AbstractShellHandler {
|
||||
private _endpoint: string | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._endpoint = userContext?.databaseAccount?.properties?.vcoreMongoEndpoint;
|
||||
}
|
||||
|
||||
public getShellName(): string {
|
||||
return "MongoDB VCore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup commands for MongoDB VCore shell:
|
||||
*
|
||||
* 1. Check if mongosh is already installed
|
||||
* 2. Download mongosh package if not installed
|
||||
* 3. Extract the package to access mongosh binaries
|
||||
* 4. Move extracted files to ~/mongosh directory
|
||||
* 5. Add mongosh binary path to system PATH
|
||||
* 6. Apply PATH changes by sourcing .bashrc
|
||||
*
|
||||
* Each command runs conditionally only if mongosh
|
||||
* is not already present in the environment.
|
||||
*/
|
||||
public getSetUpCommands(): string[] {
|
||||
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 && mv mongosh-${PACKAGE_VERSION}-linux-x64/* ~/mongosh/; fi`,
|
||||
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
|
||||
"source ~/.bashrc",
|
||||
];
|
||||
}
|
||||
|
||||
public getConnectionCommand(): string {
|
||||
if (!this._endpoint) {
|
||||
return `echo '${this.getShellName()} endpoint not found.'`;
|
||||
}
|
||||
|
||||
const userName = userContext.vcoreMongoConnectionParams.adminLogin;
|
||||
return `mongosh "mongodb+srv://${userName}:@${this._endpoint}/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000"`;
|
||||
}
|
||||
|
||||
public getTerminalSuppressedData(): string {
|
||||
return "Warning: Non-Genuine MongoDB Detected";
|
||||
}
|
||||
}
|
||||
207
src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx
Normal file
207
src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { AbstractShellHandler } from "Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler";
|
||||
import { IDisposable, ITerminalAddon, Terminal } from "@xterm/xterm";
|
||||
|
||||
interface IAttachOptions {
|
||||
bidirectional?: boolean;
|
||||
startMarker?: string;
|
||||
shellHandler?: AbstractShellHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal addon that attaches a terminal to a WebSocket for bidirectional
|
||||
* communication with Azure CloudShell.
|
||||
*
|
||||
* Features:
|
||||
* - Manages bidirectional data flow between terminal and CloudShell WebSocket
|
||||
* - Processes special status messages within the data stream
|
||||
* - Controls terminal output display during shell initialization
|
||||
* - Supports shell-specific customizations via AbstractShellHandler
|
||||
*
|
||||
* @implements {ITerminalAddon}
|
||||
*/
|
||||
export class AttachAddon implements ITerminalAddon {
|
||||
private _socket: WebSocket;
|
||||
private _bidirectional: boolean;
|
||||
private _disposables: IDisposable[] = [];
|
||||
private _socketData: string;
|
||||
|
||||
private _allowTerminalWrite: boolean = true;
|
||||
|
||||
private _startMarker: string;
|
||||
private _shellHandler: AbstractShellHandler;
|
||||
|
||||
constructor(socket: WebSocket, options?: IAttachOptions) {
|
||||
this._socket = socket;
|
||||
// always set binary type to arraybuffer, we do not handle blobs
|
||||
this._socket.binaryType = "arraybuffer";
|
||||
this._bidirectional = !(options && options.bidirectional === false);
|
||||
this._startMarker = options?.startMarker;
|
||||
this._shellHandler = options?.shellHandler;
|
||||
this._socketData = "";
|
||||
this._allowTerminalWrite = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the addon with the provided terminal
|
||||
*
|
||||
* Sets up event listeners for terminal input and WebSocket messages.
|
||||
* Links the terminal input to the WebSocket and vice versa.
|
||||
*
|
||||
* @param {Terminal} terminal - The XTerm terminal instance
|
||||
*/
|
||||
public activate(terminal: Terminal): void {
|
||||
this.addMessageListener(terminal);
|
||||
if (this._bidirectional) {
|
||||
this._disposables.push(terminal.onData((data) => this._sendData(data)));
|
||||
this._disposables.push(terminal.onBinary((data) => this._sendBinary(data)));
|
||||
}
|
||||
|
||||
this._disposables.push(addSocketListener(this._socket, "close", () => this.dispose()));
|
||||
this._disposables.push(addSocketListener(this._socket, "error", () => this.dispose()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a message listener to process data from the WebSocket
|
||||
*
|
||||
* Handles:
|
||||
* - Status message extraction (between ie_us and ie_ue markers)
|
||||
* - Partial message accumulation
|
||||
* - Shell initialization messages
|
||||
* - Suppression of unwanted shell output
|
||||
*
|
||||
* @param {Terminal} terminal - The XTerm terminal instance
|
||||
*/
|
||||
public addMessageListener(terminal: Terminal): void {
|
||||
this._disposables.push(
|
||||
addSocketListener(this._socket, "message", (ev) => {
|
||||
let data: ArrayBuffer | string = ev.data;
|
||||
const startStatusJson = "ie_us";
|
||||
const endStatusJson = "ie_ue";
|
||||
|
||||
if (typeof data === "object") {
|
||||
const enc = new TextDecoder("utf-8");
|
||||
data = enc.decode(ev.data as ArrayBuffer);
|
||||
}
|
||||
|
||||
// for example of json object look in TerminalHelper in the socket.onMessage
|
||||
if (data.includes(startStatusJson) && data.includes(endStatusJson)) {
|
||||
// process as one line
|
||||
const statusData = data.split(startStatusJson)[1].split(endStatusJson)[0];
|
||||
data = data.replace(statusData, "");
|
||||
data = data.replace(startStatusJson, "");
|
||||
data = data.replace(endStatusJson, "");
|
||||
} else if (data.includes(startStatusJson)) {
|
||||
// check for start
|
||||
const partialStatusData = data.split(startStatusJson)[1];
|
||||
this._socketData += partialStatusData;
|
||||
data = data.replace(partialStatusData, "");
|
||||
data = data.replace(startStatusJson, "");
|
||||
} else if (data.includes(endStatusJson)) {
|
||||
// check for end and process the command
|
||||
const partialStatusData = data.split(endStatusJson)[0];
|
||||
this._socketData += partialStatusData;
|
||||
data = data.replace(partialStatusData, "");
|
||||
data = data.replace(endStatusJson, "");
|
||||
this._socketData = "";
|
||||
} else if (this._socketData.length > 0) {
|
||||
// check if the line is all data then just concatenate
|
||||
this._socketData += data;
|
||||
data = "";
|
||||
}
|
||||
|
||||
if (this._allowTerminalWrite && data.includes(this._startMarker)) {
|
||||
this._allowTerminalWrite = false;
|
||||
terminal.write(`Preparing ${this._shellHandler.getShellName()} environment...\r\n`);
|
||||
}
|
||||
|
||||
if (this._allowTerminalWrite) {
|
||||
const suppressedData = this._shellHandler?.getTerminalSuppressedData();
|
||||
const hasSuppressedData = suppressedData && suppressedData.length > 0;
|
||||
|
||||
if (!hasSuppressedData || !data.includes(suppressedData)) {
|
||||
terminal.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.includes(this._shellHandler.getConnectionCommand())) {
|
||||
this._allowTerminalWrite = true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
for (const d of this._disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends string data from the terminal to the WebSocket
|
||||
*
|
||||
* @param {string} data - The data to send
|
||||
*/
|
||||
private _sendData(data: string): void {
|
||||
if (!this._checkOpenSocket()) {
|
||||
return;
|
||||
}
|
||||
this._socket.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends binary data from the terminal to the WebSocket
|
||||
*
|
||||
* @param {string} data - The string data to convert to binary and send
|
||||
*/
|
||||
private _sendBinary(data: string): void {
|
||||
if (!this._checkOpenSocket()) {
|
||||
return;
|
||||
}
|
||||
const buffer = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
buffer[i] = data.charCodeAt(i) & 255;
|
||||
}
|
||||
this._socket.send(buffer);
|
||||
}
|
||||
|
||||
private _checkOpenSocket(): boolean {
|
||||
switch (this._socket.readyState) {
|
||||
case WebSocket.OPEN:
|
||||
return true;
|
||||
case WebSocket.CONNECTING:
|
||||
throw new Error("Attach addon was loaded before socket was open");
|
||||
case WebSocket.CLOSING:
|
||||
return false;
|
||||
case WebSocket.CLOSED:
|
||||
throw new Error("Attach addon socket is closed");
|
||||
default:
|
||||
throw new Error("Unexpected socket state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener to a WebSocket and returns a disposable object
|
||||
* for cleanup
|
||||
*
|
||||
* @param {WebSocket} socket - The WebSocket instance
|
||||
* @param {K} type - The event type to listen for
|
||||
* @param {Function} handler - The event handler function
|
||||
* @returns {IDisposable} An object with a dispose method to remove the listener
|
||||
*/
|
||||
function addSocketListener<K extends keyof WebSocketEventMap>(
|
||||
socket: WebSocket,
|
||||
type: K,
|
||||
handler: (this: WebSocket, ev: WebSocketEventMap[K]) => void,
|
||||
): IDisposable {
|
||||
socket.addEventListener(type, handler);
|
||||
return {
|
||||
dispose: () => {
|
||||
if (!handler) {
|
||||
// Already disposed
|
||||
return;
|
||||
}
|
||||
socket.removeEventListener(type, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
52
src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx
Normal file
52
src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalKind } from "../../../../Contracts/ViewModels";
|
||||
|
||||
/**
|
||||
* Utility function to wait for a specified duration
|
||||
*/
|
||||
export const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Extract host from a URL
|
||||
*/
|
||||
export const getHostFromUrl = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch (error) {
|
||||
console.error("Invalid URL:", error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const askConfirmation = async (terminal: Terminal, question: string): Promise<boolean> => {
|
||||
terminal.writeln(`\n${question} (Y/N)`);
|
||||
terminal.focus();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const keyListener = terminal.onKey(({ key }: { key: string }) => {
|
||||
keyListener.dispose();
|
||||
terminal.writeln(key);
|
||||
return resolve(key.toLowerCase() === "y");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the current locale for API requests
|
||||
*/
|
||||
export const getLocale = (): string => {
|
||||
const langLocale = navigator.language;
|
||||
return langLocale && langLocale.length > 2 ? langLocale : "en-us";
|
||||
};
|
||||
|
||||
export const getShellNameForDisplay = (terminalKind: TerminalKind): string => {
|
||||
switch (terminalKind) {
|
||||
case TerminalKind.Postgres:
|
||||
return "PostgreSQL";
|
||||
case TerminalKind.Mongo:
|
||||
case TerminalKind.VCoreMongo:
|
||||
return "MongoDB";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
48
src/Explorer/Tabs/CloudShellTab/Utils/RegionUtils.tsx
Normal file
48
src/Explorer/Tabs/CloudShellTab/Utils/RegionUtils.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// Check this list for regional availability https://azure.microsoft.com/en-us/explore/global-infrastructure/products-by-region/table
|
||||
const validCloudShellRegions = new Set([
|
||||
"westus",
|
||||
"southcentralus",
|
||||
"eastus",
|
||||
"northeurope",
|
||||
"westeurope",
|
||||
"centralindia",
|
||||
"southeastasia",
|
||||
"westcentralus",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalizes a region name to ensure compatibility with Azure CloudShell.
|
||||
*
|
||||
* Azure CloudShell is only available in specific regions. This function:
|
||||
* 1. Maps certain regions to their CloudShell-supported equivalents (e.g., centralus → westcentralus)
|
||||
* 2. Validates if the region is supported by CloudShell
|
||||
* 3. Falls back to the default region if the provided region is unsupported
|
||||
*
|
||||
* This ensures users can connect to CloudShell even when their database is in a region
|
||||
* where CloudShell isn't directly available, by routing to the nearest supported region.
|
||||
*
|
||||
* @param region - The source region (typically from the user's database account location)
|
||||
* @param defaultCloudshellRegion - Fallback region to use if the provided region is not supported
|
||||
* @returns A valid CloudShell region name that's as close as possible to the requested region
|
||||
*
|
||||
* @example
|
||||
* // Returns "westcentralus" (mapped region)
|
||||
* getNormalizedRegion("centralus", "westus")
|
||||
*
|
||||
* @example
|
||||
* // Returns "westus" (default region) since "antarctica" isn't supported
|
||||
* getNormalizedRegion("antarctica", "westus")
|
||||
*/
|
||||
export const getNormalizedRegion = (region: string, defaultCloudshellRegion: string) => {
|
||||
if (!region) {
|
||||
return defaultCloudshellRegion;
|
||||
}
|
||||
|
||||
const regionMap: Record<string, string> = {
|
||||
centralus: "westcentralus",
|
||||
eastus2: "eastus",
|
||||
};
|
||||
|
||||
const normalizedRegion = regionMap[region.toLowerCase()] || region;
|
||||
return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion;
|
||||
};
|
||||
39
src/Explorer/Tabs/CloudShellTab/Utils/TerminalLogFormats.tsx
Normal file
39
src/Explorer/Tabs/CloudShellTab/Utils/TerminalLogFormats.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// This file contains utility functions and constants for formatting terminal messages in a cloud shell environment.
|
||||
// It includes ANSI escape codes for colors and functions to format messages for different log levels (info, success, warning, error).
|
||||
export const TERMINAL_COLORS = {
|
||||
RESET: "\x1b[0m",
|
||||
BRIGHT: "\x1b[1m",
|
||||
DIM: "\x1b[2m",
|
||||
BLACK: "\x1b[30m",
|
||||
RED: "\x1b[31m",
|
||||
GREEN: "\x1b[32m",
|
||||
YELLOW: "\x1b[33m",
|
||||
BLUE: "\x1b[34m",
|
||||
MAGENTA: "\x1b[35m",
|
||||
CYAN: "\x1b[36m",
|
||||
WHITE: "\x1b[37m",
|
||||
BG_BLACK: "\x1b[40m",
|
||||
BG_RED: "\x1b[41m",
|
||||
BG_GREEN: "\x1b[42m",
|
||||
BG_YELLOW: "\x1b[43m",
|
||||
BG_BLUE: "\x1b[44m",
|
||||
BG_MAGENTA: "\x1b[45m",
|
||||
BG_CYAN: "\x1b[46m",
|
||||
BG_WHITE: "\x1b[47m",
|
||||
};
|
||||
|
||||
export const START_MARKER = `echo "START INITIALIZATION" > /dev/null`;
|
||||
export const END_MARKER = `echo "END INITIALIZATION" > /dev/null`;
|
||||
|
||||
// Terminal message formatting functions
|
||||
export const formatInfoMessage = (message: string): string =>
|
||||
`${TERMINAL_COLORS.BRIGHT}${TERMINAL_COLORS.CYAN}${message}${TERMINAL_COLORS.RESET}`;
|
||||
|
||||
export const formatSuccessMessage = (message: string): string =>
|
||||
`${TERMINAL_COLORS.BRIGHT}${TERMINAL_COLORS.GREEN}${message}${TERMINAL_COLORS.RESET}`;
|
||||
|
||||
export const formatWarningMessage = (message: string): string =>
|
||||
`${TERMINAL_COLORS.BRIGHT}${TERMINAL_COLORS.YELLOW}${message}${TERMINAL_COLORS.RESET}`;
|
||||
|
||||
export const formatErrorMessage = (message: string): string =>
|
||||
`${TERMINAL_COLORS.BRIGHT}${TERMINAL_COLORS.RED}${message}${TERMINAL_COLORS.RESET}`;
|
||||
@@ -16,10 +16,20 @@ export const ConnectTab: React.FC = (): JSX.Element => {
|
||||
const [primaryReadonlyMasterKey, setPrimaryReadonlyMasterKey] = useState<string>("");
|
||||
const [secondaryReadonlyMasterKey, setSecondaryReadonlyMasterKey] = useState<string>("");
|
||||
const uri: string = userContext.databaseAccount.properties?.documentEndpoint;
|
||||
const primaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryMasterKey}`;
|
||||
const secondaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryMasterKey}`;
|
||||
const primaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryReadonlyMasterKey}`;
|
||||
const secondaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryReadonlyMasterKey}`;
|
||||
const primaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryMasterKey};`;
|
||||
const secondaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryMasterKey};`;
|
||||
const primaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryReadonlyMasterKey};`;
|
||||
const secondaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryReadonlyMasterKey};`;
|
||||
const maskedValue: string =
|
||||
"*********************************************************************************************************************************";
|
||||
const [showPrimaryMasterKey, setShowPrimaryMasterKey] = useState<boolean>(false);
|
||||
const [showSecondaryMasterKey, setShowSecondaryMasterKey] = useState<boolean>(false);
|
||||
const [showPrimaryReadonlyMasterKey, setShowPrimaryReadonlyMasterKey] = useState<boolean>(false);
|
||||
const [showSecondaryReadonlyMasterKey, setShowSecondaryReadonlyMasterKey] = useState<boolean>(false);
|
||||
const [showPrimaryConnectionStr, setShowPrimaryConnectionStr] = useState<boolean>(false);
|
||||
const [showSecondaryConnectionStr, setShowSecondaryConnectionStr] = useState<boolean>(false);
|
||||
const [showPrimaryReadonlyConnectionStr, setShowPrimaryReadonlyConnectionStr] = useState<boolean>(false);
|
||||
const [showSecondaryReadonlyConnectionStr, setShowSecondaryReadonlyConnectionStr] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
@@ -62,55 +72,97 @@ export const ConnectTab: React.FC = (): JSX.Element => {
|
||||
root: { width: "100%" },
|
||||
field: { backgroundColor: "rgb(230, 230, 230)" },
|
||||
fieldGroup: { borderColor: "rgb(138, 136, 134)" },
|
||||
suffix: {
|
||||
backgroundColor: "rgb(230, 230, 230)",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const renderCopyButton = (selector: string) => (
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked(selector)}
|
||||
styles={{
|
||||
root: {
|
||||
height: "100%",
|
||||
backgroundColor: "rgb(230, 230, 230)",
|
||||
border: "none",
|
||||
},
|
||||
rootHovered: {
|
||||
backgroundColor: "rgb(220, 220, 220)",
|
||||
},
|
||||
rootPressed: {
|
||||
backgroundColor: "rgb(210, 210, 210)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", padding: 16 }}>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 16, margin: 10 }}>
|
||||
<TextField
|
||||
label="URI"
|
||||
id="uriTextfield"
|
||||
readOnly
|
||||
value={uri}
|
||||
styles={textfieldStyles}
|
||||
onRenderSuffix={() => renderCopyButton("#uriTextfield")}
|
||||
/>
|
||||
<div style={{ width: 32 }}></div>
|
||||
</Stack>
|
||||
|
||||
<Pivot>
|
||||
{userContext.hasWriteAccess && (
|
||||
<PivotItem headerText="Read-write Keys">
|
||||
<Stack style={{ margin: 10 }}>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField label="URI" id="uriTextfield" readOnly value={uri} styles={textfieldStyles} />
|
||||
<IconButton iconProps={{ iconName: "Copy" }} onClick={() => onCopyBtnClicked("#uriTextfield")} />
|
||||
</Stack>
|
||||
|
||||
<Stack style={{ margin: 10, overflow: "auto", maxHeight: "calc(100vh - 300px)" }}>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField
|
||||
label="PRIMARY KEY"
|
||||
id="primaryKeyTextfield"
|
||||
readOnly
|
||||
value={primaryMasterKey}
|
||||
value={showPrimaryMasterKey ? primaryMasterKey : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showPrimaryMasterKey && {
|
||||
onRenderSuffix: () => renderCopyButton("#primaryKeyTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: showPrimaryMasterKey ? "Hide3" : "View" }}
|
||||
onClick={() => setShowPrimaryMasterKey(!showPrimaryMasterKey)}
|
||||
/>
|
||||
<IconButton iconProps={{ iconName: "Copy" }} onClick={() => onCopyBtnClicked("#primaryKeyTextfield")} />
|
||||
</Stack>
|
||||
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField
|
||||
label="SECONDARY KEY"
|
||||
id="secondaryKeyTextfield"
|
||||
readOnly
|
||||
value={secondaryMasterKey}
|
||||
value={showSecondaryMasterKey ? secondaryMasterKey : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showSecondaryMasterKey && {
|
||||
onRenderSuffix: () => renderCopyButton("#secondaryKeyTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#secondaryKeyTextfield")}
|
||||
iconProps={{ iconName: showSecondaryMasterKey ? "Hide3" : "View" }}
|
||||
onClick={() => setShowSecondaryMasterKey(!showSecondaryMasterKey)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField
|
||||
label="PRIMARY CONNECTION STRING"
|
||||
id="primaryConStrTextfield"
|
||||
readOnly
|
||||
value={primaryConnectionStr}
|
||||
value={showPrimaryConnectionStr ? primaryConnectionStr : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showPrimaryConnectionStr && {
|
||||
onRenderSuffix: () => renderCopyButton("#primaryConStrTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#primaryConStrTextfield")}
|
||||
iconProps={{ iconName: showPrimaryConnectionStr ? "Hide3" : "View" }}
|
||||
onClick={() => setShowPrimaryConnectionStr(!showPrimaryConnectionStr)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
@@ -118,34 +170,36 @@ export const ConnectTab: React.FC = (): JSX.Element => {
|
||||
label="SECONDARY CONNECTION STRING"
|
||||
id="secondaryConStrTextfield"
|
||||
readOnly
|
||||
value={secondaryConnectionStr}
|
||||
value={showSecondaryConnectionStr ? secondaryConnectionStr : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showSecondaryConnectionStr && {
|
||||
onRenderSuffix: () => renderCopyButton("#secondaryConStrTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#secondaryConStrTextfield")}
|
||||
iconProps={{ iconName: showSecondaryConnectionStr ? "Hide3" : "View" }}
|
||||
onClick={() => setShowSecondaryConnectionStr(!showSecondaryConnectionStr)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
)}
|
||||
<PivotItem headerText="Read-only Keys">
|
||||
<Stack style={{ margin: 10 }}>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField label="URI" id="uriReadOnlyTextfield" readOnly value={uri} styles={textfieldStyles} />
|
||||
<IconButton iconProps={{ iconName: "Copy" }} onClick={() => onCopyBtnClicked("#uriReadOnlyTextfield")} />
|
||||
</Stack>
|
||||
<Stack style={{ margin: 10, overflow: "auto", maxHeight: "calc(100vh - 300px)" }}>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField
|
||||
label="PRIMARY READ-ONLY KEY"
|
||||
id="primaryReadonlyKeyTextfield"
|
||||
readOnly
|
||||
value={primaryReadonlyMasterKey}
|
||||
value={showPrimaryReadonlyMasterKey ? primaryReadonlyMasterKey : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showPrimaryReadonlyMasterKey && {
|
||||
onRenderSuffix: () => renderCopyButton("#primaryReadonlyKeyTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#primaryReadonlyKeyTextfield")}
|
||||
iconProps={{ iconName: showPrimaryReadonlyMasterKey ? "Hide3" : "View" }}
|
||||
onClick={() => setShowPrimaryReadonlyMasterKey(!showPrimaryReadonlyMasterKey)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
@@ -153,12 +207,15 @@ export const ConnectTab: React.FC = (): JSX.Element => {
|
||||
label="SECONDARY READ-ONLY KEY"
|
||||
id="secondaryReadonlyKeyTextfield"
|
||||
readOnly
|
||||
value={secondaryReadonlyMasterKey}
|
||||
value={showSecondaryReadonlyMasterKey ? secondaryReadonlyMasterKey : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showSecondaryReadonlyMasterKey && {
|
||||
onRenderSuffix: () => renderCopyButton("#secondaryReadonlyKeyTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#secondaryReadonlyKeyTextfield")}
|
||||
iconProps={{ iconName: showSecondaryReadonlyMasterKey ? "Hide3" : "View" }}
|
||||
onClick={() => setShowSecondaryReadonlyMasterKey(!showSecondaryReadonlyMasterKey)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
@@ -166,25 +223,31 @@ export const ConnectTab: React.FC = (): JSX.Element => {
|
||||
label="PRIMARY READ-ONLY CONNECTION STRING"
|
||||
id="primaryReadonlyConStrTextfield"
|
||||
readOnly
|
||||
value={primaryReadonlyConnectionStr}
|
||||
value={showPrimaryReadonlyConnectionStr ? primaryReadonlyConnectionStr : maskedValue}
|
||||
styles={textfieldStyles}
|
||||
{...(showPrimaryReadonlyConnectionStr && {
|
||||
onRenderSuffix: () => renderCopyButton("#primaryReadonlyConStrTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#primaryReadonlyConStrTextfield")}
|
||||
iconProps={{ iconName: showPrimaryReadonlyConnectionStr ? "Hide3" : "View" }}
|
||||
onClick={() => setShowPrimaryReadonlyConnectionStr(!showPrimaryReadonlyConnectionStr)}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
|
||||
<TextField
|
||||
label="SECONDARY READ-ONLY CONNECTION STRING"
|
||||
id="secondaryReadonlyConStrTextfield"
|
||||
value={secondaryReadonlyConnectionStr}
|
||||
value={showSecondaryReadonlyConnectionStr ? secondaryReadonlyConnectionStr : maskedValue}
|
||||
readOnly
|
||||
styles={textfieldStyles}
|
||||
{...(showSecondaryReadonlyConnectionStr && {
|
||||
onRenderSuffix: () => renderCopyButton("#secondaryReadonlyConStrTextfield"),
|
||||
})}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Copy" }}
|
||||
onClick={() => onCopyBtnClicked("#secondaryReadonlyConStrTextfield")}
|
||||
iconProps={{ iconName: showSecondaryReadonlyConnectionStr ? "Hide3" : "View" }}
|
||||
onClick={() => setShowSecondaryReadonlyConnectionStr(!showSecondaryReadonlyConnectionStr)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -49,12 +49,14 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { userContext } from "UserContext";
|
||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||
import { Allotment } from "allotment";
|
||||
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
|
||||
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { format } from "react-string-format";
|
||||
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg";
|
||||
import NewDocumentIcon from "../../../../images/NewDocument.svg";
|
||||
import UploadIcon from "../../../../images/Upload_16x16.svg";
|
||||
import DiscardIcon from "../../../../images/discard.svg";
|
||||
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
|
||||
import SaveIcon from "../../../../images/save-cosmos.svg";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import * as HeadersUtility from "../../../Common/HeadersUtility";
|
||||
@@ -131,6 +133,14 @@ export const useDocumentsTabStyles = makeStyles({
|
||||
backgroundColor: "white",
|
||||
zIndex: 1,
|
||||
},
|
||||
refreshBtn: {
|
||||
position: "absolute",
|
||||
top: "3px",
|
||||
right: "4px",
|
||||
float: "right",
|
||||
zIndex: 1,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
deleteProgressContent: {
|
||||
paddingTop: tokens.spacingVerticalL,
|
||||
},
|
||||
@@ -296,6 +306,7 @@ export type ButtonsDependencies = {
|
||||
selectedRows: Set<TableRowId>;
|
||||
editorState: ViewModels.DocumentExplorerState;
|
||||
isPreferredApiMongoDB: boolean;
|
||||
clientWriteEnabled: boolean;
|
||||
onNewDocumentClick: UiKeyboardEvent;
|
||||
onSaveNewDocumentClick: UiKeyboardEvent;
|
||||
onRevertNewDocumentClick: UiKeyboardEvent;
|
||||
@@ -319,6 +330,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps =>
|
||||
hasPopup: true,
|
||||
disabled:
|
||||
useSelectedNode.getState().isDatabaseNodeOrNoneSelected() ||
|
||||
!useClientWriteEnabled.getState().clientWriteEnabled ||
|
||||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
|
||||
};
|
||||
};
|
||||
@@ -337,6 +349,7 @@ export const getTabsButtons = ({
|
||||
selectedRows,
|
||||
editorState,
|
||||
isPreferredApiMongoDB,
|
||||
clientWriteEnabled,
|
||||
onNewDocumentClick,
|
||||
onSaveNewDocumentClick,
|
||||
onRevertNewDocumentClick,
|
||||
@@ -362,6 +375,7 @@ export const getTabsButtons = ({
|
||||
hasPopup: false,
|
||||
disabled:
|
||||
!getNewDocumentButtonState(editorState).enabled ||
|
||||
!clientWriteEnabled ||
|
||||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
|
||||
id: NEW_DOCUMENT_BUTTON_ID,
|
||||
});
|
||||
@@ -379,6 +393,7 @@ export const getTabsButtons = ({
|
||||
hasPopup: false,
|
||||
disabled:
|
||||
!getSaveNewDocumentButtonState(editorState).enabled ||
|
||||
!clientWriteEnabled ||
|
||||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
|
||||
id: SAVE_BUTTON_ID,
|
||||
});
|
||||
@@ -413,6 +428,7 @@ export const getTabsButtons = ({
|
||||
hasPopup: false,
|
||||
disabled:
|
||||
!getSaveExistingDocumentButtonState(editorState).enabled ||
|
||||
!clientWriteEnabled ||
|
||||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
|
||||
id: UPDATE_BUTTON_ID,
|
||||
});
|
||||
@@ -445,7 +461,7 @@ export const getTabsButtons = ({
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
|
||||
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected() || !clientWriteEnabled,
|
||||
id: DELETE_BUTTON_ID,
|
||||
});
|
||||
}
|
||||
@@ -619,6 +635,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
);
|
||||
|
||||
// State
|
||||
const clientWriteEnabled = useClientWriteEnabled((state) => state.clientWriteEnabled);
|
||||
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
|
||||
readDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
|
||||
leftPaneWidthPercent: 35,
|
||||
@@ -756,16 +773,17 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
[_collection, _partitionKey],
|
||||
);
|
||||
const partitionKeyPropertyHeaders: string[] = useMemo(
|
||||
() => _collection?.partitionKeyPropertyHeaders || partitionKey?.paths,
|
||||
[_collection?.partitionKeyPropertyHeaders, partitionKey?.paths],
|
||||
);
|
||||
let partitionKeyProperties = useMemo(
|
||||
() =>
|
||||
partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
|
||||
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
|
||||
),
|
||||
[partitionKeyPropertyHeaders],
|
||||
isPreferredApiMongoDB && partitionKey?.systemKey
|
||||
? []
|
||||
: _collection?.partitionKeyPropertyHeaders || partitionKey?.paths,
|
||||
[_collection?.partitionKeyPropertyHeaders, partitionKey?.paths, partitionKey?.systemKey, isPreferredApiMongoDB],
|
||||
);
|
||||
let partitionKeyProperties = useMemo(() => {
|
||||
return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
|
||||
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
|
||||
);
|
||||
}, [partitionKeyPropertyHeaders]);
|
||||
|
||||
const getInitialColumnSelection = () => {
|
||||
const defaultColumnsIds = ["id"];
|
||||
@@ -856,6 +874,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
selectedRows,
|
||||
editorState,
|
||||
isPreferredApiMongoDB,
|
||||
clientWriteEnabled,
|
||||
onNewDocumentClick,
|
||||
onSaveNewDocumentClick,
|
||||
onRevertNewDocumentClick,
|
||||
@@ -1028,6 +1047,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
);
|
||||
|
||||
const selectedDocumentId = documentIds[clickedRowIndex as number];
|
||||
const originalPartitionKeyValue = selectedDocumentId.partitionKeyValue;
|
||||
selectedDocumentId.partitionKeyValue = partitionKeyValueArray;
|
||||
|
||||
onExecutionErrorChange(false);
|
||||
@@ -1063,6 +1083,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
setColumnDefinitionsFromDocument(documentContent);
|
||||
},
|
||||
(error) => {
|
||||
// in case of any kind of failures of accidently changing partition key, restore the original
|
||||
// so that when user navigates away from current document and comes back,
|
||||
// it doesnt fail to load due to using the invalid partition keys
|
||||
selectedDocumentId.partitionKeyValue = originalPartitionKeyValue;
|
||||
onExecutionErrorChange(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
|
||||
@@ -1270,6 +1294,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
selectedRows,
|
||||
editorState,
|
||||
isPreferredApiMongoDB,
|
||||
clientWriteEnabled,
|
||||
onNewDocumentClick,
|
||||
onSaveNewDocumentClick,
|
||||
onRevertNewDocumentClick,
|
||||
@@ -1282,6 +1307,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
selectedRows,
|
||||
editorState,
|
||||
isPreferredApiMongoDB,
|
||||
clientWriteEnabled,
|
||||
onNewDocumentClick,
|
||||
onSaveNewDocumentClick,
|
||||
onRevertNewDocumentClick,
|
||||
@@ -1700,7 +1726,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
renderObjectForEditor = (value: unknown): string => MongoUtility.tojson(value, null, false);
|
||||
|
||||
const _hasShardKeySpecified = (document: unknown): boolean => {
|
||||
return Boolean(extractPartitionKeyValues(document, _getPartitionKeyDefinition() as PartitionKeyDefinition));
|
||||
const partitionKeyDefinition: PartitionKeyDefinition = _getPartitionKeyDefinition() as PartitionKeyDefinition;
|
||||
return partitionKeyDefinition.systemKey || Boolean(extractPartitionKeyValues(document, partitionKeyDefinition));
|
||||
};
|
||||
|
||||
const _getPartitionKeyDefinition = (): DataModels.PartitionKey => {
|
||||
@@ -1724,7 +1751,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
return partitionKey;
|
||||
};
|
||||
|
||||
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
||||
partitionKeyProperties = partitionKeyProperties.map((partitionKeyProperty, i) => {
|
||||
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||
}
|
||||
@@ -2074,8 +2101,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
|
||||
return (
|
||||
<CosmosFluentProvider className={styles.container}>
|
||||
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
|
||||
<div className={styles.filterRow}>
|
||||
<div data-test={"DocumentsTab"} className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
|
||||
<div data-test={"DocumentsTab/Filter"} className={styles.filterRow}>
|
||||
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
||||
<InputDataList
|
||||
dropdownOptions={getFilterChoices()}
|
||||
@@ -2092,6 +2119,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
data-test={"DocumentsTab/ApplyFilter"}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (isExecuting) {
|
||||
@@ -2117,7 +2145,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}}
|
||||
>
|
||||
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
|
||||
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
||||
<div
|
||||
data-test={"DocumentsTab/DocumentsPane"}
|
||||
style={{ height: "100%", width: "100%", overflow: "hidden" }}
|
||||
ref={tableContainerRef}
|
||||
>
|
||||
<div className={styles.tableContainer}>
|
||||
<div
|
||||
style={
|
||||
@@ -2144,10 +2176,23 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
isColumnSelectionDisabled={isPreferredApiMongoDB}
|
||||
/>
|
||||
</div>
|
||||
{tableContainerSizePx?.width >= calculateOffset(selectedColumnIds.length) + 200 && (
|
||||
<div
|
||||
title="Refresh"
|
||||
className={styles.refreshBtn}
|
||||
role="button"
|
||||
onClick={() => refreshDocumentsGrid(false)}
|
||||
aria-label="Refresh"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img src={RefreshIcon} alt="Refresh" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tableItems.length > 0 && (
|
||||
<a
|
||||
className={styles.loadMore}
|
||||
data-test={"DocumentsTab/LoadMore"}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => loadNextPage(documentsIterator.iterator, false)}
|
||||
@@ -2159,7 +2204,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane minSize={30}>
|
||||
<div style={{ height: "100%", width: "100%" }}>
|
||||
<div data-test={"DocumentsTab/ResultsPane"} style={{ height: "100%", width: "100%" }}>
|
||||
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
||||
<EditorReact
|
||||
language={"json"}
|
||||
|
||||
@@ -233,7 +233,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
aria-label="Select column"
|
||||
size="small"
|
||||
icon={<MoreHorizontalRegular />}
|
||||
style={{ position: "absolute", right: 0, backgroundColor: tokens.colorNeutralBackground1 }}
|
||||
style={{ position: "absolute", right: 10, backgroundColor: tokens.colorNeutralBackground1 }}
|
||||
/>
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
>
|
||||
<div
|
||||
className="tab-pane active"
|
||||
data-test="DocumentsTab"
|
||||
role="tabpanel"
|
||||
style={
|
||||
{
|
||||
@@ -15,6 +16,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
>
|
||||
<div
|
||||
className="___11ktxfv_0000000 f1o614cb fy9rknc f22iagw fsnqrgy f1f5gg8d fjodcmx f122n59 f1f09k3d fg706s2 frpde29"
|
||||
data-test="DocumentsTab/Filter"
|
||||
>
|
||||
<span>
|
||||
SELECT * FROM c
|
||||
@@ -49,6 +51,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
<Button
|
||||
appearance="primary"
|
||||
aria-label="Apply filter"
|
||||
data-test="DocumentsTab/ApplyFilter"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
size="small"
|
||||
@@ -65,6 +68,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
preferredSize="35%"
|
||||
>
|
||||
<div
|
||||
data-test="DocumentsTab/DocumentsPane"
|
||||
style={
|
||||
{
|
||||
"height": "100%",
|
||||
@@ -126,6 +130,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
minSize={30}
|
||||
>
|
||||
<div
|
||||
data-test="DocumentsTab/ResultsPane"
|
||||
style={
|
||||
{
|
||||
"height": "100%",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-console */
|
||||
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
||||
import { AuthType } from "AuthType";
|
||||
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
|
||||
import { SplitterDirection } from "Common/Splitter";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
@@ -21,6 +22,7 @@ import { QueryConstants } from "Shared/Constants";
|
||||
import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { Allotment } from "allotment";
|
||||
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
|
||||
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { TabsState, useTabs } from "hooks/useTabs";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
@@ -484,7 +486,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveQueryButton.enabled,
|
||||
disabled:
|
||||
!this.saveQueryButton.enabled ||
|
||||
(!useClientWriteEnabled.getState().clientWriteEnabled && userContext.authType === AuthType.AAD),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -696,6 +700,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
private unsubscribeCopilotSidebar: () => void;
|
||||
private unsubscribeClientWriteEnabled: () => void;
|
||||
|
||||
componentDidMount(): void {
|
||||
useTabs.subscribe((state: TabsState) => {
|
||||
@@ -712,10 +717,17 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
document.addEventListener("keydown", this.handleCopilotKeyDown);
|
||||
|
||||
this.unsubscribeClientWriteEnabled = useClientWriteEnabled.subscribe(() => {
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
document.removeEventListener("keydown", this.handleCopilotKeyDown);
|
||||
if (this.unsubscribeClientWriteEnabled) {
|
||||
this.unsubscribeClientWriteEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
private getEditorAndQueryResult(): JSX.Element {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import { IsValidCosmosDbResourceId } from "Utils/ValidationUtils";
|
||||
import DiscardIcon from "../../../images/discard.svg";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
@@ -57,7 +58,7 @@ export default abstract class ScriptTabBase extends TabsBase implements ViewMode
|
||||
}
|
||||
|
||||
this.id = editable.observable<string>();
|
||||
this.id.validations([ScriptTabBase._isValidId]);
|
||||
this.id.validations([IsValidCosmosDbResourceId]);
|
||||
|
||||
this.editorContent = editable.observable<string>();
|
||||
this.editorContent.validations([ScriptTabBase._isNotEmpty]);
|
||||
@@ -262,29 +263,6 @@ export default abstract class ScriptTabBase extends TabsBase implements ViewMode
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
private static _isValidId(id: string): boolean {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidStartCharacters = /^[/?#\\]/;
|
||||
if (invalidStartCharacters.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidMiddleCharacters = /^.+[/?#\\]/;
|
||||
if (invalidMiddleCharacters.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidEndCharacters = /.*[/?#\\ ]$/;
|
||||
if (invalidEndCharacters.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static _isNotEmpty(value: string): boolean {
|
||||
return !!value;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user