diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56ed46edc..228470f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/images/dotnet.png b/images/dotnet.png index c8972efdc..fb00ecf91 100644 Binary files a/images/dotnet.png and b/images/dotnet.png differ diff --git a/images/golang.svg b/images/golang.svg new file mode 100644 index 000000000..d706fb571 --- /dev/null +++ b/images/golang.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/images/springboot.svg b/images/springboot.svg new file mode 100644 index 000000000..ee451deaa --- /dev/null +++ b/images/springboot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/less/quickstart.less b/less/quickstart.less index dd9b2e33a..539d63448 100644 --- a/less/quickstart.less +++ b/less/quickstart.less @@ -1,927 +1,923 @@ @import "./Common/Constants"; html { - font-family: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif; - padding: 0px; - margin: 0px; - overflow: hidden; + font-family: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif; + padding: 0px; + margin: 0px; + overflow: hidden; } body { - font-family: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif; - font-size: 12px; + font-family: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif; + font-size: 12px; } .fixedleftpane { - background: #2f2d2d; - height: 100vh; - width: 80px; - float: left; + background: #2f2d2d; + height: 100vh; + width: 80px; + float: left; } #divQuickStart, #divExplorer { - display: inline-block; - width: 100%; - white-space: nowrap; + display: inline-block; + width: 100%; + white-space: nowrap; } #imgiconwidth1 { - width: 72%; + width: 72%; } #Quickstart { - text-align: center; - width: 80px; - height: 60px; - margin: 0 auto; - padding-top: 5px; - position: relative; + text-align: center; + width: 80px; + height: 60px; + margin: 0 auto; + padding-top: 5px; + position: relative; } .collectionheading { - text-transform: uppercase; - font-size: 10px; + text-transform: uppercase; + font-size: 10px; } #Quickstart #imgiconwidth1 { - width: 24px; - height: 24px; - position: absolute; - right: 27px; + width: 24px; + height: 24px; + position: absolute; + right: 27px; } .topSelected { - border-left: 4px solid @AccentMediumHigh; - background: #666666; + border-left: 4px solid @AccentMediumHigh; + background: #666666; } .topSelected:hover { - border-left: 4px solid @AccentMediumHigh; - background: #666666!important; - cursor: default!important; + border-left: 4px solid @AccentMediumHigh; + background: #666666 !important; + cursor: default !important; } #Quickstart:hover span.activemenu, #Quickstart:active span.activemenu { - color: #fff; + color: #fff; } #Explorer:hover span.menuExplorer, #Explorer:active span.menuExplorer { - color: #fff; + color: #fff; } menuQuickStart { - margin-left: 0; - padding-left: 0; - display: block; - right: 12px; - top: 30px; - position: absolute; + margin-left: 0; + padding-left: 0; + display: block; + right: 12px; + top: 30px; + position: absolute; } #Explorer { - text-align: center; - display: inline-block; - width: 80px; - height: 60px; - margin: 0 auto; - padding-top: 9px; - position: relative; + text-align: center; + display: inline-block; + width: 80px; + height: 60px; + margin: 0 auto; + padding-top: 9px; + position: relative; } #Explorer #imgiconwidth1, .feedbackstyle #imgiconwidth1, .settingstyle #imgiconwidth1 { - width: 24px; - height: 24px; - position: absolute; - right: 30px; + width: 24px; + height: 24px; + position: absolute; + right: 30px; } #Explorer span.menuExplorer { - margin-left: 0; - padding-left: 0; - display: block; - right: 19px; - top: 33px; - position: absolute; + margin-left: 0; + padding-left: 0; + display: block; + right: 19px; + top: 33px; + position: absolute; } .feedbackstyle span.menuExplorer { - margin-left: 0; - padding-left: 0; - display: block; - right: 19px; - top: 33px; - position: absolute; + margin-left: 0; + padding-left: 0; + display: block; + right: 19px; + top: 33px; + position: absolute; } .settingstyle span.menuExplorer { - margin-left: 0; - padding-left: 0; - display: block; - right: 19px; - top: 33px; - position: absolute; + margin-left: 0; + padding-left: 0; + display: block; + right: 19px; + top: 33px; + position: absolute; } .content { - display: inline-block; - width: 100%; - transition: all .4s ease-in-out; - -ms-transition: all .4s ease-in-out; - -webkit-transition: all .4s ease-in-out; - -moz-transition: all .4s ease-in-out; - height: 100vh; + display: inline-block; + width: 100%; + transition: all 0.4s ease-in-out; + -ms-transition: all 0.4s ease-in-out; + -webkit-transition: all 0.4s ease-in-out; + -moz-transition: all 0.4s ease-in-out; + height: 100vh; } .mini { - width: 0%; - float: left; - transition: all .4s ease-in-out; - -webkit-transition: all .4s ease-in-out; - -moz-transition: all .4s ease-in-out; - height: 100vh; - background-color: white; + width: 0%; + float: left; + transition: all 0.4s ease-in-out; + -webkit-transition: all 0.4s ease-in-out; + -moz-transition: all 0.4s ease-in-out; + height: 100vh; + background-color: white; } #sidebar-wrapper { - z-index: 1000; - position: fixed; - left: 250px; - width: 0; - height: 100%; - margin-left: -250px; - overflow-y: auto; - background: white; - -webkit-transition: all 0.5s ease; - -moz-transition: all 0.5s ease; - -o-transition: all 0.5s ease; - transition: all 0.5s ease; + z-index: 1000; + position: fixed; + left: 250px; + width: 0; + height: 100%; + margin-left: -250px; + overflow-y: auto; + background: white; + -webkit-transition: all 0.5s ease; + -moz-transition: all 0.5s ease; + -o-transition: all 0.5s ease; + transition: all 0.5s ease; } .toggle-left { - width: 0%; - overflow: hidden; + width: 0%; + overflow: hidden; } .toggle-minicontent { - width: 100%; + width: 100%; } .toggle-maincontent { - width: 100%; + width: 100%; } .toggle-mini { - width: 50px; + width: 50px; } .toggle-main { - width: 100%; + width: 100%; } .activepartitionmode { - background-color: @AccentMediumHigh; + background-color: @AccentMediumHigh; } .paddingpartition { - color: white; - padding-left: 15px; - padding-top: 25px; + color: white; + padding-left: 15px; + padding-top: 25px; } .paddingspan2 { - padding-top: 20px; - color: #000; - padding-left: 15px; + padding-top: 20px; + color: #000; + padding-left: 15px; } .paddingspan4 { - padding-top: 20px; - padding-bottom: 20px; - color: white; - padding-left: 15px; + padding-top: 20px; + padding-bottom: 20px; + color: white; + padding-left: 15px; } .whitegroove { - width: 344px; - border: groove; + width: 344px; + border: groove; } .dropdownbtn { - color: white; - width: 340px; - background: #262626; + color: white; + width: 340px; + background: #262626; } .queryclr { - color: white; - background: #262626; + color: white; + background: #262626; } .pointer { - cursor: pointer; + cursor: pointer; } -#tbodycontent>tr>td { - border-bottom: 1px solid #cccccc; +#tbodycontent > tr > td { + border-bottom: 1px solid #cccccc; } -#tbodycontent>tr:last-child>td { - border-bottom: 1px solid #ddd; +#tbodycontent > tr:last-child > td { + border-bottom: 1px solid #ddd; } .gridRowSelected { - background-color: #DEF; + background-color: #def; } .gridRowSelected:hover { - background-color: #DEF!important; - cursor: initial; + background-color: #def !important; + cursor: initial; } .collectionNodeSelected { - background-color: #DEF; + background-color: #def; } .collectionNodeSelected:hover { - background-color: #DEF!important; - cursor: default!important; + background-color: #def !important; + cursor: default !important; } .databaseNodeSelected { - background-color: #DEF; + background-color: #def; } .databaseNodeSelected:hover { - background-color: #DEF!important; - cursor: default!important; + background-color: #def !important; + cursor: default !important; } .leftsidepanle-hr { - margin: 16px 0px; - border-top: 1px solid #eee; - margin-left: -17px; - width: 100%; - color: 1px solid #53575B; + margin: 16px 0px; + border-top: 1px solid #eee; + margin-left: -17px; + width: 100%; + color: 1px solid #53575b; } .partitioning-btn { - padding-bottom: 16px; + padding-bottom: 16px; } .btncreatecoll1 { - border: 1px solid @AccentMediumHigh; - background-color: @AccentMediumHigh; - color: #fff; - padding: 2px 30px; - cursor: pointer; - font-size: 12px; + border: 1px solid @AccentMediumHigh; + background-color: @AccentMediumHigh; + color: #fff; + padding: 2px 30px; + cursor: pointer; + font-size: 12px; } .btncreatecoll1:hover { - background: @AccentMediumHigh; - color: #fff; - border-color: @AccentMediumHigh; - cursor: pointer; - font-size: 12px; + background: @AccentMediumHigh; + color: #fff; + border-color: @AccentMediumHigh; + cursor: pointer; + font-size: 12px; } .btncreatecoll1:active { - border: 1px solid #0072c6; - background-color: #0072c6; - color: white; - padding: 2px 30px; - cursor: pointer; - font-size: 12px; + border: 1px solid #0072c6; + background-color: #0072c6; + color: white; + padding: 2px 30px; + cursor: pointer; + font-size: 12px; } .btncreatecoll1-off { - border: 1px solid #969696; - background-color: #000; - color: white; - padding: 2px 30px; - cursor: pointer; - font-size: 12px; - margin-left: -5px; + border: 1px solid #969696; + background-color: #000; + color: white; + padding: 2px 30px; + cursor: pointer; + font-size: 12px; + margin-left: -5px; } .leftpanel-okbut { - padding: 20px 0px 24px 30px; + padding: 20px 0px 24px 30px; } .btnpricepad { - margin-left: 24px; + margin-left: 24px; } .collid { - background: #fff; - width: calc(~"100% - 80px"); + background: #fff; + width: calc(~"100% - 80px"); } .textfontclr { - color: #000; + color: #000; } .collid-white { - width: 100%; - border: solid 1px #DDD; + width: 100%; + border: solid 1px #ddd; } .plusimg-but { - background-image: url(../images/plus_normal.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/plus_normal.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .plusimg-but:hover { - background-image: url(../images/plus_hover.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/plus_hover.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .plusimg-but:active { - background-image: url(../images/plus_pressed.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/plus_pressed.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .plusimg-but:disabled { - background-image: url(../images/plus_disabled.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/plus_disabled.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .minusimg-but { - background-image: url(../images/minus_normal.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/minus_normal.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .minusimg-but:hover { - background-image: url(../images/minus_hover.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/minus_hover.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .minusimg-but:active { - background-image: url(../images/minus_pressed.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/minus_pressed.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .minusimg-but:disabled { - background-image: url(../images/minus_disabled.svg); - background-repeat: no-repeat; - padding: 6px 16px; - position: static; - padding-top: 4px; + background-image: url(../images/minus_disabled.svg); + background-repeat: no-repeat; + padding: 6px 16px; + position: static; + padding-top: 4px; } .firstdivbg { - padding: @MediumSpace 0px @DefaultSpace (2 * @LargeSpace); - background-color: @BaseLight; + padding: @MediumSpace 0px @DefaultSpace (2 * @LargeSpace); + background-color: @BaseLight; } p { - margin: 0 0 4px; - color: #000; + margin: 0 0 4px; + color: #000; } .closeImg { - float: right; - margin: 0px 20px 0px 0px; - cursor: pointer; - padding: 6px 20px 20px 6px; - width: 20px; - height: 20px; + float: right; + margin: 0px 20px 0px 0px; + cursor: pointer; + padding: 6px 20px 20px 6px; + width: 20px; + height: 20px; } .seconddivpadding { - padding-top: 16px; + padding-top: 16px; } .seconddivbg { - height: 100vh; - padding-left: 32px; - padding-top: 16px; + height: 100vh; + padding-left: 32px; + padding-top: 16px; } .pkPadding { - padding-top: 12px; + padding-top: 12px; } .mandatoryStar { - color: #ff0707; - font-size: 14px; - font-weight: bold; + color: #ff0707; + font-size: 14px; + font-weight: bold; } .pricingtierimg { - padding-left: 20px; - padding-top: 10px; - padding-bottom: 20px; + padding-left: 20px; + padding-top: 10px; + padding-bottom: 20px; } .headerline { - color: @BaseDark; - font-size: 16px; - border-bottom: 1px solid @BaseMedium; + color: @BaseDark; + font-size: 16px; + border-bottom: 1px solid @BaseMedium; } .partitionkeystyle { - font-size: 10px; + font-size: 10px; } .arrowprice { - margin-left: 230px; + margin-left: 230px; } .paddingspan { - padding: 20px; - color: white; - font-size: 14px; + padding: 20px; + color: white; + font-size: 14px; } input::-webkit-calendar-picker-indicator { - opacity: 100; + opacity: 100; } .paddingspan3 { - color: white; - font-size: 14px; - position: absolute; - width: 100%; - height: 100px; - bottom: 150px; + color: white; + font-size: 14px; + position: absolute; + width: 100%; + height: 100px; + bottom: 150px; } .paddingspan4 { - padding-top: 20px; - padding-left: 20px; - color: white; - font-size: 14px; + padding-top: 20px; + padding-left: 20px; + color: white; + font-size: 14px; } .closebtnn { - float: right; - padding: 0 10px; - cursor: pointer; + float: right; + padding: 0 10px; + cursor: pointer; } label { - white-space: nowrap; - font: 12px "Segoe UI"; + white-space: nowrap; + font: 12px "Segoe UI"; } .Introlines { - padding-top: 27px; - padding-left: 25px; + padding-top: 27px; + padding-left: 25px; } .Introline1 { - font-size: 16px; + font-size: 16px; } .Introline2 { - font-size: 14px; - padding-top: 10px; + font-size: 14px; + padding-top: 10px; } .datalist-arrow { - position: relative; + position: relative; } .datalist-arrow:hover:after { - background: #969696; + background: #969696; } .datalist-arrow:focus:after, .datalist-arrow:active:after { - background: #1EBBEE; + background: #1ebbee; } input::-webkit-calendar-picker-indicator::after { - content: '\276F'; - right: 0; - top: -8%; - display: block; - width: 27px; - height: 25px; - line-height: 25px; - color: #fff; - text-align: center; - pointer-events: none; - transform: rotate(90deg); + content: "\276F"; + right: 0; + top: -8%; + display: block; + width: 27px; + height: 25px; + line-height: 25px; + color: #fff; + text-align: center; + pointer-events: none; + transform: rotate(90deg); } .datalist-arrow:after:hover { - content: '\276F'; - position: absolute; - right: 1px; - top: 6%; - transform: rotate(90deg); - display: block; - width: 27px; - height: 25px; - line-height: 25px; - color: #fff; - text-align: center; - pointer-events: none; - background-color: #1EBBEE; + content: "\276F"; + position: absolute; + right: 1px; + top: 6%; + transform: rotate(90deg); + display: block; + width: 27px; + height: 25px; + line-height: 25px; + color: #fff; + text-align: center; + pointer-events: none; + background-color: #1ebbee; } .Introline3 { - padding-top: 10px; - font-size: 14px; - font-weight: 1000; + padding-top: 10px; + font-size: 14px; + font-weight: 1000; } .collectionCollapsed { - color: black; - font-weight: 400; - font-size: 14px; - position: relative; - display: block; - padding: 8px 15px; - cursor: pointer; - margin-right: 13px; - border: 1px solid #fff; + color: black; + font-weight: 400; + font-size: 14px; + position: relative; + display: block; + padding: 8px 15px; + cursor: pointer; + margin-right: 13px; + border: 1px solid #fff; } .collectionCollapsed:hover { - background: #EEEEEE; + background: #eeeeee; } .collectionCollapsed:active { - border: solid 1px @AccentMediumHigh; + border: solid 1px @AccentMediumHigh; } .collectionCollapsed:focus { - border: Solid 1px @AccentMediumHigh; + border: Solid 1px @AccentMediumHigh; } .arrowCollapsed { - cursor: pointer; - width: 16px; - height: 16px; - transform: rotate(-90deg) translateX(-100%); - -webkit-transform: rotate(-90deg) translateX(-100%); - -ms-transform: rotate(-90deg) translateX(-100%); - margin: -30px 3px 0px 2px; + cursor: pointer; + width: 16px; + height: 16px; + transform: rotate(-90deg) translateX(-100%); + -webkit-transform: rotate(-90deg) translateX(-100%); + -ms-transform: rotate(-90deg) translateX(-100%); + margin: -30px 3px 0px 2px; } .leftarrowCollapsed { - padding: 2px 4px 4px 5px; - border: solid 1px #FFF; - margin: 6px 4px 0px -11px; + padding: 2px 4px 4px 5px; + border: solid 1px #fff; + margin: 6px 4px 0px -11px; } .leftarrowCollapsed:hover { - background-color: #EEEEEE; + background-color: #eeeeee; } .leftarrowCollapsed:active { - border: solid 1px @AccentMediumHigh; + border: solid 1px @AccentMediumHigh; } .leftarrowCollapsed:focus { - border: Solid 1px @AccentMediumHigh; + border: Solid 1px @AccentMediumHigh; } .qslevel { - padding-top: 10px; - padding-left: 10px; - width: 60%; - min-width: 960px; + padding-top: 10px; + padding-left: 10px; + width: 60%; + min-width: 960px; } .nav-tabs-margin { - margin-top: 20px; + margin-top: 20px; } .numbersize { - font-size: 30px; - display: inline; - font-weight: 600; + font-size: 30px; + display: inline; + font-weight: 600; } .numbersizePadding { - padding-right: 5px; + padding-right: 5px; } .numberheading { - display: inline; - position: absolute; - padding-top: 10px; - font-size: 16px; - padding-left: 15px; + display: inline; + position: absolute; + padding-top: 10px; + font-size: 16px; + padding-left: 15px; } -.numberheading>p { - padding-top: 10px; - font-size: 12px; +.numberheading > p { + padding-top: 10px; + font-size: 12px; } -.numberheading>ul { - padding-top: 10px; - padding-left: 0px; - list-style-type: none; +.numberheading > ul { + padding-top: 10px; + padding-left: 0px; + list-style-type: none; } .numberheading ul li { - padding-bottom: 5px; + padding-bottom: 5px; } -.numberheading>ul>li>a { - font-size: 12px; - color: #0058ad; +.numberheading > ul > li > a { + font-size: 12px; + color: #0058ad; } -.netApp { - padding-bottom: 80px; -} - -.pythonApp { - padding-bottom: 45px; +.sampleApp { + padding-bottom: 45px; } .step1 { - padding-bottom: 110px; + padding-bottom: 110px; } -.step1>input { - font-size: 12px; +.step1 > input { + font-size: 12px; } .btncreatecoll { - background: #0058ad; - color: #fff; - padding: 5px 20px; - cursor: pointer; - font-size: 12px; - border: 1px solid #0058ad; + background: #0058ad; + color: #fff; + padding: 5px 20px; + cursor: pointer; + font-size: 12px; + border: 1px solid #0058ad; } .btncreatecoll:hover { - background-color: #0074e0; + background-color: #0074e0; } .atags:focus { - color: @AccentMediumHigh; + color: @AccentMediumHigh; } .atags { - color: @AccentMediumHigh; - font-weight: 400; - cursor: pointer + color: @AccentMediumHigh; + font-weight: 400; + cursor: pointer; } .qsmenuicons { - width: 25px; - height: 25px; - margin-right: 5px; + width: 25px; + height: 25px; + margin-right: 5px; } .HeaderBg { - background-color: #202428; - height: 60px; + background-color: #202428; + height: 60px; } .title { - color: @AccentMediumHigh; - font-size: 20px; - padding-left: 10px; + color: @AccentMediumHigh; + font-size: 20px; + padding-left: 10px; } .items { - padding-left: 24px; - padding-top: 15px; + padding-left: 24px; + padding-top: 15px; } .divmenuquickstartpadding { - padding-left: 24px; - padding-bottom: 8px; + padding-left: 24px; + padding-bottom: 8px; } .menuQuickStart { - font-size: 12px; - color: white; - padding-left: 10px; + font-size: 12px; + color: white; + padding-left: 10px; } .menuExplorer { - font-size: 12px; - color: white; - padding-left: 20px; + font-size: 12px; + color: white; + padding-left: 20px; } .activemenu { - color: #fff; + color: #fff; } .rightarrowimg { - padding-left: 5px; - padding-bottom: 2px; + padding-left: 5px; + padding-bottom: 2px; } a:hover, a:visited, a:active, a:link { - text-decoration: none; + text-decoration: none; } .command { - padding: 8px; + padding: 8px; } .command:hover { - background-color: #E6E6E6; - cursor: pointer; - padding-bottom: 12px; + background-color: #e6e6e6; + cursor: pointer; + padding-bottom: 12px; } .command:active { - background-color: #CCCCCC; - border: solid 1px @AccentMediumHigh; + background-color: #cccccc; + border: solid 1px @AccentMediumHigh; } .command:focus { - padding: 7px 8px 11px 8px; - border: solid 1px @AccentMediumHigh; - outline: none; + padding: 7px 8px 11px 8px; + border: solid 1px @AccentMediumHigh; + outline: none; } -.nav>li>a:focus { - background-color: white; +.nav > li > a:focus { + background-color: white; } .commandIcon { - margin: 0 5px 0 0; - vertical-align: text-top; - width: 18px; - height: 18px; + margin: 0 5px 0 0; + vertical-align: text-top; + width: 18px; + height: 18px; } .iconpadclick { - background-color: #e6e6e6; - cursor: pointer; - border: 1px solid #1ebbee; - padding: 5px; + background-color: #e6e6e6; + cursor: pointer; + border: 1px solid #1ebbee; + padding: 5px; } .divimgleftarrow { - display: inline-block; - margin-top: 16px; - margin-right: 10px; + display: inline-block; + margin-top: 16px; + margin-right: 10px; } .divimgleftarrow:hover { - background-color: #e6e6e6; - cursor: pointer; - border: 1px solid #1ebbee; + background-color: #e6e6e6; + cursor: pointer; + border: 1px solid #1ebbee; } .adddeliconspan { - display: none; - float: right; - padding: 5px; + display: none; + float: right; + padding: 5px; } .spanparent:hover .adddeliconspan { - display: inline; + display: inline; } .spanchild:hover .adddeliconspan { - display: inline; + display: inline; } .collectiontitle { - font-size: 14px; - text-transform: uppercase; + font-size: 14px; + text-transform: uppercase; } .titlepadcol { - padding-left: 20px; - font-weight: 500; - height: 28px; - display: inline-block; - padding-top: 5px; + padding-left: 20px; + font-weight: 500; + height: 28px; + display: inline-block; + padding-top: 5px; } .btnmainslide { - height: 14px; - margin-top: 14px; - cursor: pointer; + height: 14px; + margin-top: 14px; + cursor: pointer; } .well { - padding: 19px 0px; - padding-top: 0px; - margin-bottom: 20px; - border: 0px; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0); - background: white; + padding: 19px 0px; + padding-top: 0px; + margin-bottom: 20px; + border: 0px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0); + background: white; } .splitter { - z-index: 1; - border-left: 5px solid white; - width: 8px; - border-right: 1px solid #cccccc; - float: left; - height: 100%; - position: absolute; - margin-left: 240px; - padding: 0px; - background-color: white; + z-index: 1; + border-left: 5px solid white; + width: 8px; + border-right: 1px solid #cccccc; + float: left; + height: 100%; + position: absolute; + margin-left: 240px; + padding: 0px; + background-color: white; } .testClass { - padding-left: 30px; + padding-left: 30px; } .level { - padding-left: 16px; - padding-top: 0px; + padding-left: 16px; + padding-top: 0px; } .imgiconwidth { - margin-right: 5px; + margin-right: 5px; } .id { - padding-left: 8px; - color: #000; - font-weight: bold; - margin-left: 6px; + padding-left: 8px; + color: #000; + font-weight: bold; + margin-left: 6px; } .documentsGridHeaderContainer { - padding-left: 5px; - padding-right: 15px; - width: 200px; + padding-left: 5px; + padding-right: 15px; + width: 200px; } .documentsGridHeader { - padding: 8px; - color: #000; - font-weight: bold; + padding: 8px; + color: #000; + font-weight: bold; } .fixedWidthHeader { - width: 82px; + width: 82px; } .tabdocuments { - padding: 4px 4px -1px 0px; + padding: 4px 4px -1px 0px; } -#divcontent>.mongoDocumentEditor .monaco-editor.vs .redsquiggly { - display: none !important; +#divcontent > .mongoDocumentEditor .monaco-editor.vs .redsquiggly { + display: none !important; } td a { - color: #393939; + color: #393939; } td a:hover { - color: #393939; + color: #393939; } .loadMore { - padding-left: 32%; - cursor: pointer; + padding-left: 32%; + cursor: pointer; } .table-fixed thead { - width: 97%; - padding-left: 18px; + width: 97%; + padding-left: 18px; } .table-fixed tbody { - height: 510px; - overflow-y: auto; - width: 100%; - overflow-x: hidden; + height: 510px; + overflow-y: auto; + width: 100%; + overflow-x: hidden; } .table-fixed thead, @@ -929,383 +925,383 @@ td a:hover { .table-fixed tr, .table-fixed td, .table-fixed th { - display: block; + display: block; } .table-fixed tbody td, -.table-fixed thead>tr>th { - float: left; - border-bottom-width: 0; +.table-fixed thead > tr > th { + float: left; + border-bottom-width: 0; } a:hover, a:visited, a:active, a:link { - text-decoration: none; + text-decoration: none; } .tabs { - position: relative; - clear: both; - margin: 15px 0 25px 0; - display: table; - width: 100%; + position: relative; + clear: both; + margin: 15px 0 25px 0; + display: table; + width: 100%; } .tab { - float: left; + float: left; } .tab label { - padding: 10px; - border: 1px solid #bbbbbb; - margin-left: -1px; - position: inherit; - left: 1px; - color: #393939; + padding: 10px; + border: 1px solid #bbbbbb; + margin-left: -1px; + position: inherit; + left: 1px; + color: #393939; } -.tab [type=radio] { - display: none; +.tab [type="radio"] { + display: none; } .tabcontent { - position: absolute; - top: 30px; - left: 0; - right: 0; - bottom: 0; - padding: 15px 0px 20px 0; + position: absolute; + top: 30px; + left: 0; + right: 0; + bottom: 0; + padding: 15px 0px 20px 0; } -.tab [type=radio]:checked~label { - border: 1px solid #0072c6; - background-color: @AccentMediumHigh; - color: white; - z-index: 2; +.tab [type="radio"]:checked ~ label { + border: 1px solid #0072c6; + background-color: @AccentMediumHigh; + color: white; + z-index: 2; } -.tab [type=radio]:checked~label:hover { - border: 1px solid @AccentMediumHigh; - background-color: @AccentMediumHigh; - color: white; - z-index: 2; +.tab [type="radio"]:checked ~ label:hover { + border: 1px solid @AccentMediumHigh; + background-color: @AccentMediumHigh; + color: white; + z-index: 2; } -.tab [type=radio]:checked~label:active { - border: 1px solid #0072c6; - background-color: #0072c6; - color: white; - z-index: 2; +.tab [type="radio"]:checked ~ label:active { + border: 1px solid #0072c6; + background-color: #0072c6; + color: white; + z-index: 2; } -.tab [type=radio]:checked~label~.tabcontent { - z-index: 1; - display: initial; +.tab [type="radio"]:checked ~ label ~ .tabcontent { + z-index: 1; + display: initial; } -.tab [type=radio]:not(:checked)~label:hover { - border: 1px solid #969696; - background-color: #969696; - color: white; - cursor: pointer; +.tab [type="radio"]:not(:checked) ~ label:hover { + border: 1px solid #969696; + background-color: #969696; + color: white; + cursor: pointer; } -.tab [type=radio]:not(:checked)~label~.tabcontent { - display: none; +.tab [type="radio"]:not(:checked) ~ label ~ .tabcontent { + display: none; } ::-webkit-input-placeholder { - color: #969696; + color: #969696; } ::-moz-placeholder { - color: #969696; + color: #969696; } :-ms-input-placeholder { - color: #969696; + color: #969696; } :-moz-placeholder { - color: #969696; + color: #969696; } ::-ms-expand { - color: #969696; + color: #969696; } .form-errors { - color: white; - padding-left: 12px; + color: white; + padding-left: 12px; } .atagdetails { - padding-left: 55px!important; + padding-left: 55px !important; } .path { - color: lightgray; - font-style: italic; - padding-top: 12px; - padding-left: 20px; + color: lightgray; + font-style: italic; + padding-top: 12px; + padding-left: 20px; } .queryPath { - line-height: 16px; - padding-left: 33px; - padding-bottom: 12px; + line-height: 16px; + padding-left: 33px; + padding-bottom: 12px; } .filterDocCollapsed { - padding-left: 20px; + padding-left: 20px; } .filterDocCollapsed.active { - display: block; + display: block; } .filterDocExpanded { - padding-left: 20px; + padding-left: 20px; } .filterDocExpanded.active { - display: block; + display: block; } .filterbuttonpad { - padding-top: 10px; + padding-top: 10px; } .filterbtnstyle { - background: @AccentMediumHigh; - width: 90px; - height: 25px; - color: white; - border: none; - margin-left: 16px !important; + background: @AccentMediumHigh; + width: 90px; + height: 25px; + color: white; + border: none; + margin-left: 16px !important; } .filterbtnstyle:hover { - background: @AccentMediumHigh; - width: 90px; - height: 25px; - color: white; - border: none; - margin-left: 16px; + background: @AccentMediumHigh; + width: 90px; + height: 25px; + color: white; + border: none; + margin-left: 16px; } .filterbtnstyle:active { - background: #0072c6; - width: 90px; - height: 25px; - color: white; - border: none; - margin-left: 16px; + background: #0072c6; + width: 90px; + height: 25px; + color: white; + border: none; + margin-left: 16px; } .filterbtnstyle:focus { - background: #0072c6; - width: 90px; - height: 25px; - color: white; - border: none; - margin-left: 16px; - border: 1px solid #0072c6; + background: #0072c6; + width: 90px; + height: 25px; + color: white; + border: none; + margin-left: 16px; + border: 1px solid #0072c6; } .filterbtnstyle:not(:enabled) { - background: lightgray; - width: 90px; - height: 25px; - color: white; - border: none; + background: lightgray; + width: 90px; + height: 25px; + color: white; + border: none; } .hrline1 { - color: #d6d7d8; - margin-left: -20px; + color: #d6d7d8; + margin-left: -20px; } .filtdocheader { - font-size: 18px; + font-size: 18px; } .editFilter { - margin-left: 20px; + margin-left: 20px; } .filterdivs { - padding-top: 24px; - height: 45px; - margin-bottom: 20px; + padding-top: 24px; + height: 45px; + margin-bottom: 20px; } .filterclose { - padding: 0 10px; - cursor: pointer; + padding: 0 10px; + cursor: pointer; } .queryResultpreviousImg { - height: 14px; - width: 14px; - margin-right: 2px; + height: 14px; + width: 14px; + margin-right: 2px; } .queryResultnextImg { - height: 14px; - width: 14px; - margin-left: 2px; + height: 14px; + width: 14px; + margin-left: 2px; } .rowoverride { - margin-left: 7px; - margin-top: 20px; + margin-left: 7px; + margin-top: 20px; } .tab-content-override { - padding-left: 5px; - padding-top: 20px; + padding-left: 5px; + padding-top: 20px; } .paddingspan4 { - padding-top: 20px; - color: white; - padding-left: 25px; - padding-right: 25px; + padding-top: 20px; + color: white; + padding-left: 25px; + padding-right: 25px; } .colResizePointer { - cursor: col-resize; + cursor: col-resize; } -.nav-tabs>li>a { - border-radius: 2px 2px 0 0; - padding: 8px 0px 4px 0px; - color: #393939; - width: 130px; - text-align: center; - margin-right: 0px; - position: relative; +.nav-tabs > li > a { + border-radius: 2px 2px 0 0; + padding: 8px 0px 4px 0px; + color: #393939; + width: 130px; + text-align: center; + margin-right: 0px; + position: relative; } -.nav-tabs>li.active>a, -.nav-tabs>li.active>a:focus, -.nav-tabs>li.active>a:hover { - border-bottom-color: #FFF; +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:focus, +.nav-tabs > li.active > a:hover { + border-bottom-color: #fff; } .tabList { - float: left; - margin-bottom: -15px !important; + float: left; + margin-bottom: -15px !important; } .tab_Content { - width: 130px; - border-right: 1px solid #e0e0e0; - padding: 0px 22px 0px 17px; - margin-left: -1px; + width: 130px; + border-right: 1px solid #e0e0e0; + padding: 0px 22px 0px 17px; + margin-left: -1px; } .tab_Content:hover { - width: 130px; - border-right: 1px solid #e0e0e0; - padding: 0px 22px 0px 17px; - margin-left: -1px; + width: 130px; + border-right: 1px solid #e0e0e0; + padding: 0px 22px 0px 17px; + margin-left: -1px; } .tab_Content:active { - width: 130px; - border-right: 1px; - padding: 0px 22px 0px 17px; - margin-left: -1px; + width: 130px; + border-right: 1px; + padding: 0px 22px 0px 17px; + margin-left: -1px; } .tabtext-center { - max-width: 110px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding-left: 2px; + max-width: 110px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 2px; } .tabIconSection { - width: 30px; - float: right; - top: -16px; - position: relative; - padding: 2px 12px 0 13px; + width: 30px; + float: right; + top: -16px; + position: relative; + padding: 2px 12px 0 13px; } -.nav-tabs>li>a:active { - background-color: #e0e0e0; - border-color: @AccentMediumHigh; +.nav-tabs > li > a:active { + background-color: #e0e0e0; + border-color: @AccentMediumHigh; } -.nav-tabs>li>a:active .tab_Content { - border: transparent; - width: 130px; +.nav-tabs > li > a:active .tab_Content { + border: transparent; + width: 130px; } .close-Icon { - background-image: url(../images/close-black.svg); - background-repeat: no-repeat; - padding: 0px 0px 0px 11px; + background-image: url(../images/close-black.svg); + background-repeat: no-repeat; + padding: 0px 0px 0px 11px; } .close-Icon:hover { - background-image: url(../images/close-black-hover.svg); - background-repeat: no-repeat; - padding: 0px 0px 0px 11px; + background-image: url(../images/close-black-hover.svg); + background-repeat: no-repeat; + padding: 0px 0px 0px 11px; } .clickableLink { - color: @AccentMediumHigh; - font-family: 'Segoe UI'; - font-size: 12px; - cursor: pointer; + color: @AccentMediumHigh; + font-family: "Segoe UI"; + font-size: 12px; + cursor: pointer; } .clickableLink:hover { - background-color: #e7f6fc; + background-color: #e7f6fc; } .clickableLink:active { - background-color: #e6f8fe; + background-color: #e6f8fe; } .clickableLink:focus { - outline: 1px dashed #000000; - outline-offset: 0px; + outline: 1px dashed #000000; + outline-offset: 0px; } .paneselect { - height: 23px; + height: 23px; } .headerWithoutPartitionKey { - width: 172px; + width: 172px; } .headerWithPartitionKey { - width: 86px; + width: 86px; } -input.codeblock{ - background-color: @BaseMediumLow; - color: #252525; - border: 1px solid @BaseMediumHigh; - box-sizing: border-box; - font-size: @mediumFontSize; - height: 23px; - outline: 0; - padding: 2px 8px 4px; - width: 60%; - min-width: 960px; - cursor: text; +input.codeblock { + background-color: @BaseMediumLow; + color: #252525; + border: 1px solid @BaseMediumHigh; + box-sizing: border-box; + font-size: @mediumFontSize; + height: 23px; + outline: 0; + padding: 2px 8px 4px; + width: 60%; + min-width: 960px; + cursor: text; } -#divQuickStartConnections{ - padding-bottom: 10px; -} \ No newline at end of file +#divQuickStartConnections { + padding-bottom: 10px; +} diff --git a/package-lock.json b/package-lock.json index 2b1c80df0..e18653826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index f4b10a66d..b5914047b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 80ba367bf..b1f6a622d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -37,20 +37,51 @@ export default defineConfig({ }, { 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"], + }, + }, }, ], diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 37243de72..0b0028732 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -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 { diff --git a/src/Contracts/DataExplorerMessagesContract.ts b/src/Contracts/DataExplorerMessagesContract.ts index a38940120..c017bffa8 100644 --- a/src/Contracts/DataExplorerMessagesContract.ts +++ b/src/Contracts/DataExplorerMessagesContract.ts @@ -18,10 +18,13 @@ export type DataExploreMessageV3 = | { type: FabricMessageTypes.GetAllResourceTokens; id: string; + } + | { + type: FabricMessageTypes.OpenSettings; + settingsId: string; }; - -export type GetCosmosTokenMessageOptions = { +export interface GetCosmosTokenMessageOptions { verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"; resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges"; resourceId: string; -}; +} diff --git a/src/Contracts/FabricMessageTypes.ts b/src/Contracts/FabricMessageTypes.ts index 1d4576391..02871ca47 100644 --- a/src/Contracts/FabricMessageTypes.ts +++ b/src/Contracts/FabricMessageTypes.ts @@ -6,6 +6,7 @@ export enum FabricMessageTypes { GetAllResourceTokens = "GetAllResourceTokens", GetAccessToken = "GetAccessToken", Ready = "Ready", + OpenSettings = "OpenSettings", } export interface AuthorizationToken { diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 3cb4c7a80..3f6a795ee 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -103,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: () => { diff --git a/src/Explorer/Controls/InputDataList/InputDataList.tsx b/src/Explorer/Controls/InputDataList/InputDataList.tsx index cd31db53b..2f483d362 100644 --- a/src/Explorer/Controls/InputDataList/InputDataList.tsx +++ b/src/Explorer/Controls/InputDataList/InputDataList.tsx @@ -193,6 +193,7 @@ export const InputDataList: FC = ({ <> { + // 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(); + expect(wrapper).toMatchSnapshot(); + }); + + it("renders read-only component and matches snapshot", () => { + const { props } = setupTest(); + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx index c6a1bd9d1..89810bcb6 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx @@ -161,7 +161,7 @@ export const PartitionKeyComponent: React.FC = ({ return ( - Change {partitionKeyName.toLowerCase()} + {!isReadOnly && Change {partitionKeyName.toLowerCase()}} Current {partitionKeyName.toLowerCase()} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap new file mode 100644 index 000000000..95d87da3d --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap @@ -0,0 +1,196 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = ` + + + + Change + partition key + + + + + Current + partition key + + + Partitioning + + + + + + Non-hierarchical + + + + + + 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. + + Learn more + + + + 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. + + + +`; + +exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = ` + + + + + + Current + partition key + + + Partitioning + + + + + + Non-hierarchical + + + + + +`; diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 448b59370..2617af6ac 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -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; @@ -165,7 +166,7 @@ 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: diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index bf002bcd9..01d4d5873 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -967,7 +967,9 @@ export default class Explorer { } public async openNotebookTerminal(kind: ViewModels.TerminalKind): Promise { - 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) { diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index 3df80b67f..ea4986ee9 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -129,13 +129,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(); @@ -149,7 +150,7 @@ export function createContextCommandBarButtons( } if ( - useNotebook.getState().isShellEnabled && + (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) && !selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Cassandra" ) { @@ -470,7 +471,7 @@ function createOpenTerminalButtonByKind( iconSrc: HostedTerminalIcon, iconAlt: label, onCommandClick: () => { - if (useNotebook.getState().isNotebookEnabled) { + if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) { container.openNotebookTerminal(terminalKind); } }, diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index a40f4da99..ca92b59ed 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -23,7 +23,7 @@ import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { Platform, configContext } from "ConfigContext"; import { useDialog } from "Explorer/Controls/Dialog"; import { useDatabases } from "Explorer/useDatabases"; -import { isFabric } from "Platform/Fabric/FabricUtil"; +import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; import { AppStateComponentNames, deleteAllStates, @@ -607,441 +607,447 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ return (
- - {shouldShowQueryPageOptions && ( - - -
Page Options
-
- -
-
- Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as - many query results per page. -
- -
-
- {isCustomPageOptionSelected() && ( -
-
- Query results per page{" "} - - Enter the number of query results that should be shown per page. - -
- - { - setCustomItemPerPage(parseInt(newValue) + 1 || customItemPerPage); - }} - onDecrement={(newValue) => setCustomItemPerPage(parseInt(newValue) - 1 || customItemPerPage)} - onValidate={(newValue) => setCustomItemPerPage(parseInt(newValue) || customItemPerPage)} - min={1} - step={1} - className="textfontclr" - incrementButtonAriaLabel="Increase value by 1" - decrementButtonAriaLabel="Decrease value by 1" - /> -
- )} -
-
-
- )} - {showEnableEntraIdRbac && ( - - -
Enable Entra ID RBAC
-
- -
-
- Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra ID - RBAC. - - {" "} - Learn more{" "} - -
- -
-
-
- )} - {userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && ( - - -
Region Selection
-
- -
-
- Changes region the Cosmos Client uses to access account. -
-
- Select Region - - Changes the account endpoint used to perform client operations. - -
- option.key === selectedRegionalEndpoint)?.text - : regionOptions[0]?.text - } - onChange={handleOnSelectedRegionOptionChange} - options={regionOptions} - styles={{ root: { marginBottom: "10px" } }} - /> -
-
-
- )} - {userContext.apiType === "SQL" && !isEmulator && ( - <> - + {!isFabricNative() && ( + + {shouldShowQueryPageOptions && ( + -
Query Timeout
+
Page Options
- When a query reaches a specified time limit, a popup with an option to cancel the query will show - unless automatic cancellation has been enabled. -
- -
- {queryTimeoutEnabled && ( -
- - -
- )} -
-
- - - -
RU Limit
-
- -
-
- If a query exceeds a configured RU limit, the query will be aborted. -
- -
- {ruThresholdEnabled && ( -
- -
- )} -
-
- - - -
Default Query Results View
-
- -
-
- Select the default view to use when displaying query results. + Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as + many query results per page.
+
+
+ {isCustomPageOptionSelected() && ( +
+
+ Query results per page{" "} + + Enter the number of query results that should be shown per page. + +
+ + { + setCustomItemPerPage(parseInt(newValue) + 1 || customItemPerPage); + }} + onDecrement={(newValue) => setCustomItemPerPage(parseInt(newValue) - 1 || customItemPerPage)} + onValidate={(newValue) => setCustomItemPerPage(parseInt(newValue) || customItemPerPage)} + min={1} + step={1} + className="textfontclr" + incrementButtonAriaLabel="Increase value by 1" + decrementButtonAriaLabel="Decrease value by 1" + /> +
+ )} +
+
+
+ )} + {showEnableEntraIdRbac && ( + + +
Enable Entra ID RBAC
+
+ +
+
+ Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra + ID RBAC. + + {" "} + Learn more{" "} + +
+
- - )} + )} + {userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && ( + + +
Region Selection
+
+ +
+
+ Changes region the Cosmos Client uses to access account. +
+
+ Select Region + + Changes the account endpoint used to perform client operations. + +
+ option.key === selectedRegionalEndpoint)?.text + : regionOptions[0]?.text + } + onChange={handleOnSelectedRegionOptionChange} + options={regionOptions} + styles={{ root: { marginBottom: "10px" } }} + /> +
+
+
+ )} + {userContext.apiType === "SQL" && !isEmulator && ( + <> + + +
Query Timeout
+
+ +
+
+ When a query reaches a specified time limit, a popup with an option to cancel the query will + show unless automatic cancellation has been enabled. +
+ +
+ {queryTimeoutEnabled && ( +
+ + +
+ )} +
+
- {showRetrySettings && ( - - -
Retry Settings
-
- -
-
- Retry policy associated with throttled requests during CosmosDB queries. + + +
RU Limit
+
+ +
+
+ If a query exceeds a configured RU limit, the query will be aborted. +
+ +
+ {ruThresholdEnabled && ( +
+ +
+ )} +
+
+ + + +
Default Query Results View
+
+ +
+
+ Select the default view to use when displaying query results. +
+ +
+
+
+ + )} + + {showRetrySettings && ( + + +
Retry Settings
+
+ +
+
+ Retry policy associated with throttled requests during CosmosDB queries. +
+
+ Max retry attempts + + Max number of retries to be performed for a request. Default value 9. + +
+ setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)} + onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)} + onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)} + styles={spinButtonStyles} + /> +
+ Fixed retry interval (ms) + + Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned + as part of the response. Default value is 0 milliseconds. + +
+ setRetryInterval(parseInt(newValue) + 1000 || retryInterval)} + onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)} + onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)} + styles={spinButtonStyles} + /> +
+ Max wait time (s) + + Max wait time in seconds to wait for a request while the retries are happening. Default value 30 + seconds. + +
+ + setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds) + } + onDecrement={(newValue) => + setMaxWaitTimeInSeconds(parseInt(newValue) - 1 || MaxWaitTimeInSeconds) + } + onValidate={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) || MaxWaitTimeInSeconds)} + styles={spinButtonStyles} + />
-
- Max retry attempts - - Max number of retries to be performed for a request. Default value 9. - + + + )} + {!isEmulator && ( + + +
Enable container pagination
+
+ +
+
+ Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. +
+ setContainerPaginationEnabled(!containerPaginationEnabled)} + label="Enable container pagination" + />
- setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)} - onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)} - onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)} - styles={spinButtonStyles} - /> -
- Fixed retry interval (ms) - - Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned - as part of the response. Default value is 0 milliseconds. - + + + )} + {shouldShowCrossPartitionOption && ( + + +
Enable cross-partition query
+
+ +
+
+ Send more than one request while executing a query. More than one request is necessary if the + query is not scoped to single partition key value. +
+ setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)} + label="Enable cross-partition query" + />
- setRetryInterval(parseInt(newValue) + 1000 || retryInterval)} - onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)} - onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)} - styles={spinButtonStyles} - /> -
- Max wait time (s) - - Max wait time in seconds to wait for a request while the retries are happening. Default value 30 - seconds. - + + + )} + {shouldShowParallelismOption && ( + + +
Max degree of parallelism
+
+ +
+
+ Gets or sets the number of concurrent operations run client side during parallel query execution. + A positive property value limits the number of concurrent operations to the set value. If it is + set to less than 0, the system automatically decides the number of concurrent operations to run. +
+ + setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism) + } + onDecrement={(newValue) => + setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism) + } + onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)} + ariaLabel="Max degree of parallelism" + label="Max degree of parallelism" + />
- setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)} - onDecrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) - 1 || MaxWaitTimeInSeconds)} - onValidate={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) || MaxWaitTimeInSeconds)} - styles={spinButtonStyles} - /> -
-
-
- )} - {!isEmulator && ( - - -
Enable container pagination
-
- -
-
- Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. + + + )} + {shouldShowPriorityLevelOption && ( + + +
Priority Level
+
+ +
+
+ Sets the priority level for data-plane requests from Data Explorer when using Priority-Based + Execution. If "None" is selected, Data Explorer will not specify priority level, and the + server-side default priority level will be used. +
+
- setContainerPaginationEnabled(!containerPaginationEnabled)} - label="Enable container pagination" - /> -
- - - )} - {shouldShowCrossPartitionOption && ( - - -
Enable cross-partition query
-
- -
-
- Send more than one request while executing a query. More than one request is necessary if the query - is not scoped to single partition key value. + + + )} + {shouldShowGraphAutoVizOption && ( + + +
Display Gremlin query results as: 
+
+ +
+
+ Select Graph to automatically visualize the query results as a Graph or JSON to display the + results as JSON. +
+
- setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)} - label="Enable cross-partition query" - /> -
- - - )} - {shouldShowParallelismOption && ( - - -
Max degree of parallelism
-
- -
-
- Gets or sets the number of concurrent operations run client side during parallel query execution. A - positive property value limits the number of concurrent operations to the set value. If it is set to - less than 0, the system automatically decides the number of concurrent operations to run. + + + )} + {shouldShowCopilotSampleDBOption && ( + + +
Enable sample database
+
+ +
+
+ This is a sample database and collection with synthetic product data you can use to explore using + NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and + is created by, and maintained by Microsoft at no cost to you. +
+
- - setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism) - } - onDecrement={(newValue) => - setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism) - } - onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)} - ariaLabel="Max degree of parallelism" - label="Max degree of parallelism" - /> -
- - - )} - {shouldShowPriorityLevelOption && ( - - -
Priority Level
-
- -
-
- Sets the priority level for data-plane requests from Data Explorer when using Priority-Based - Execution. If "None" is selected, Data Explorer will not specify priority level, and the - server-side default priority level will be used. -
- -
-
-
- )} - {shouldShowGraphAutoVizOption && ( - - -
Display Gremlin query results as: 
-
- -
-
- Select Graph to automatically visualize the query results as a Graph or JSON to display the results - as JSON. -
- -
-
-
- )} - {shouldShowCopilotSampleDBOption && ( - - -
Enable sample database
-
- -
-
- This is a sample database and collection with synthetic product data you can use to explore using - NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and - is created by, and maintained by Microsoft at no cost to you. -
- -
-
-
- )} - + + + )} + + )}
diff --git a/src/Explorer/QueryCopilot/Popup/DeletePopup.tsx b/src/Explorer/QueryCopilot/Popup/DeletePopup.tsx index 2a4e29641..6e2fbed54 100644 --- a/src/Explorer/QueryCopilot/Popup/DeletePopup.tsx +++ b/src/Explorer/QueryCopilot/Popup/DeletePopup.tsx @@ -22,12 +22,17 @@ export const DeletePopup = ({ }; return ( - + - + Delete code? - + This will clear the query from the query builder pane along with all comments and also reset the prompt pane diff --git a/src/Explorer/QueryCopilot/Popup/__snapshots__/DeletePopup.test.tsx.snap b/src/Explorer/QueryCopilot/Popup/__snapshots__/DeletePopup.test.tsx.snap index 698138f5a..988fd88db 100644 --- a/src/Explorer/QueryCopilot/Popup/__snapshots__/DeletePopup.test.tsx.snap +++ b/src/Explorer/QueryCopilot/Popup/__snapshots__/DeletePopup.test.tsx.snap @@ -11,6 +11,8 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa }, } } + subtitleAriaId="deleteDialogSubTitle" + titleAriaId="deleteDialogTitle" > = ({ explorer }) => { const hasGlobalCommands = !( isFabricMirrored() || + isFabricNativeReadOnly() || userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo" ); diff --git a/src/Explorer/SplashScreen/FabricHome.tsx b/src/Explorer/SplashScreen/FabricHome.tsx index c235604d4..7db6ee041 100644 --- a/src/Explorer/SplashScreen/FabricHome.tsx +++ b/src/Explorer/SplashScreen/FabricHome.tsx @@ -5,7 +5,7 @@ import { Link, makeStyles, tokens } from "@fluentui/react-components"; import { DocumentAddRegular, LinkMultipleRegular } from "@fluentui/react-icons"; import { SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog"; import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil"; -import { isFabricNative } from "Platform/Fabric/FabricUtil"; +import { isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil"; import * as React from "react"; import { userContext } from "UserContext"; import CosmosDbBlackIcon from "../../../images/CosmosDB_black.svg"; @@ -62,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", @@ -150,7 +159,11 @@ export const FabricHomeScreen: React.FC = (props: SplashScree }, ]; - return ( + return isFabricNativeReadOnly() ? ( +
+ +
+ ) : (
@@ -159,7 +172,7 @@ export const FabricHomeScreen: React.FC = (props: SplashScree ); }; - const title = "Build your database"; + const title = isFabricNativeReadOnly() ? "Use your database" : "Build your database"; return ( <> diff --git a/src/Explorer/SplashScreen/SampleUtil.ts b/src/Explorer/SplashScreen/SampleUtil.ts index 837227c8f..4072eb011 100644 --- a/src/Explorer/SplashScreen/SampleUtil.ts +++ b/src/Explorer/SplashScreen/SampleUtil.ts @@ -1,3 +1,4 @@ +import { BackendDefaults } from "Common/Constants"; import { createCollection } from "Common/dataAccess/createCollection"; import Explorer from "Explorer/Explorer"; import { useDatabases } from "Explorer/useDatabases"; @@ -35,6 +36,11 @@ export const createContainer = async ( collectionId: containerName, databaseId: databaseName, databaseLevelThroughput: false, + partitionKey: { + paths: [`/${SAMPLE_DATA_PARTITION_KEY}`], + kind: "Hash", + version: BackendDefaults.partitionKeyVersion, + }, }; await createCollection(createRequest); await explorer.refreshAllDatabases(); @@ -47,6 +53,8 @@ export const createContainer = async ( 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 => { // TODO: keep same chunk as ContainerSampleGenerator const dataFileContent = await import( diff --git a/src/Explorer/SplashScreen/SplashScreen.tsx b/src/Explorer/SplashScreen/SplashScreen.tsx index 495bc03ee..2c7f778a4 100644 --- a/src/Explorer/SplashScreen/SplashScreen.tsx +++ b/src/Explorer/SplashScreen/SplashScreen.tsx @@ -817,7 +817,7 @@ export class SplashScreen extends React.Component { 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: "", }, diff --git a/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalComponent.tsx b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalComponent.tsx new file mode 100644 index 000000000..7ad05e876 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalComponent.tsx @@ -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 = (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
; +}; diff --git a/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx new file mode 100644 index 000000000..da692cc9b --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/CloudShellTerminalCore.tsx @@ -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 => { + 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 => { + 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 => { + 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 => { + 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); + }; + } +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx new file mode 100644 index 000000000..61daecd15 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.test.tsx @@ -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; +}) 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; + }); + + 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), + ); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx new file mode 100644 index 000000000..ee4bd01e0 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Data/CloudShellClient.tsx @@ -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 => { + return await armRequest({ + 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 => { + 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 => { + const data = { + properties: { + osType: OsType.Linux, + }, + }; + + return await armRequest({ + 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 => { + 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(); +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx b/src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx new file mode 100644 index 000000000..c87d8eed4 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx @@ -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; +}; diff --git a/src/Explorer/Tabs/CloudShellTab/README.md b/src/Explorer/Tabs/CloudShellTab/README.md new file mode 100644 index 000000000..8c5881124 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/README.md @@ -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 { + <> + +enableCloudShell: boolean + } + + class ShellTypeHandlerFactory { + <> + +getHandler(terminalKind: TerminalKind): ShellTypeHandler + +getKey(): string + } + + class AbstractShellHandler { + <> + +getShellName(): string + +getSetUpCommands(): string[] + +getConnectionCommand(): string + +getEndpoint(): string + +getTerminalSuppressedData(): string[] + +getInitialCommands(): string + } + + class CloudShellTerminalComponent { + <> + -terminalKind: TerminalKind + -shellHandler: AbstractShellHandler + +render(): ReactElement + } + + class CloudShellTerminalCore { + <> + +startCloudShellTerminal() + } + + class CloudShellClient { + + +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, + +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 CloudShell| +| 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. diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler.test.tsx new file mode 100644 index 000000000..fb89bd435 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler.test.tsx @@ -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"); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler.tsx new file mode 100644 index 000000000..f72dda8f6 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/AbstractShellHandler.tsx @@ -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"); + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.test.tsx new file mode 100644 index 000000000..ea5310a27 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.test.tsx @@ -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.'"); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx new file mode 100644 index 000000000..edd878bb4 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/CassandraShellHandler.tsx @@ -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 ""; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.test.tsx new file mode 100644 index 000000000..b320a9b68 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.test.tsx @@ -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"); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx new file mode 100644 index 000000000..fce6be513 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/MongoShellHandler.tsx @@ -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"; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.test.tsx new file mode 100644 index 000000000..ee4defaa0 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.test.tsx @@ -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(""); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx new file mode 100644 index 000000000..d2b235eca --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/PostgresShellHandler.tsx @@ -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 ""; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx new file mode 100644 index 000000000..327d40b3e --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.test.tsx @@ -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"); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx new file mode 100644 index 000000000..30ecdcaf3 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/ShellTypeFactory.tsx @@ -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 { + 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 { + const dbName = userContext.databaseAccount.name; + if (!dbName) { + return ""; + } + + const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName); + return keys?.primaryMasterKey || ""; +} diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.test.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.test.tsx new file mode 100644 index 000000000..7c0baed84 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.test.tsx @@ -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"); + }); + }); +}); diff --git a/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx b/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx new file mode 100644 index 000000000..8a3a38610 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/ShellTypes/VCoreMongoShellHandler.tsx @@ -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"; + } +} diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx new file mode 100644 index 000000000..9fc011c28 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/AttachAddOn.tsx @@ -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( + 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); + }, + }; +} diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx new file mode 100644 index 000000000..42bdfff2e --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/CommonUtils.tsx @@ -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 => 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 => { + terminal.writeln(`\n${question} (Y/N)`); + terminal.focus(); + return new Promise((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 ""; + } +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/RegionUtils.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/RegionUtils.tsx new file mode 100644 index 000000000..b36ed9e4e --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/RegionUtils.tsx @@ -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 = { + centralus: "westcentralus", + eastus2: "eastus", + }; + + const normalizedRegion = regionMap[region.toLowerCase()] || region; + return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion; +}; diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/TerminalLogFormats.tsx b/src/Explorer/Tabs/CloudShellTab/Utils/TerminalLogFormats.tsx new file mode 100644 index 000000000..be3341880 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/TerminalLogFormats.tsx @@ -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}`; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 4c96064c0..58b976d44 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -773,8 +773,11 @@ export const DocumentsTabComponent: React.FunctionComponent (partitionKey?.systemKey ? [] : _collection?.partitionKeyPropertyHeaders || partitionKey?.paths), - [_collection?.partitionKeyPropertyHeaders, partitionKey?.paths, partitionKey?.systemKey], + () => + isPreferredApiMongoDB && partitionKey?.systemKey + ? [] + : _collection?.partitionKeyPropertyHeaders || partitionKey?.paths, + [_collection?.partitionKeyPropertyHeaders, partitionKey?.paths, partitionKey?.systemKey, isPreferredApiMongoDB], ); let partitionKeyProperties = useMemo(() => { return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => @@ -2116,6 +2119,7 @@ export const DocumentsTabComponent: React.FunctionComponent -
-
- -
-
2
-
- Learn more about Azure Cosmos DB - -
-
-
-
-
-
1
-
- Open and run a sample .NET Core app -

- We created a sample .NET Core app connected to your Azure Cosmos DB Emulator instance. Download, - extract, build and run the app. -

- -
-
- -
-
2
-
- Learn more about Azure Cosmos DB. -
+
-
-
+
+
1
- Open and run a sample Java app + Create a new Java app

- We created a sample Java app connected to your Azure Cosmos DB Emulator instance. Download, extract, - build and run the app. -

- -

- Follow instructions in the readme.md to setup prerequisites needed to run Java web apps, if you - haven’t already. + Follow this + tutorial + + to create a new Java app connected to Azure Cosmos DB.

- -
-
2
-
- Learn more about Azure Cosmos DB. - -
-
+
-
-
+
+
1
- Open and run a sample Node.js app + Create a new Node.js app

- We created a sample Node.js app connected to your Azure Cosmos DB Emulator instance. Download, - extract, build and run the app. -

- -

- Run npm install and npm start, and navigate to - http://localhost:3000. + Follow this + tutorial + + to create a new Node.js app connected to Azure Cosmos DB.

- -
-
2
-
- Learn more about Azure Cosmos DB. - -
-
+
-
-
+
+
1
- Create a new Python app. + Create a new Python app

Follow this tutorial @@ -257,42 +135,73 @@

+
+
-
-
2
+
+
+
1
- Learn more about Azure Cosmos DB. - + Create a new Go app +

+ Follow this + tutorial + to create a new Go app connected to Azure Cosmos DB. +

+
+
+ +
+
+
1
+
+ Create a new Spring Boot app +

+ Follow this + tutorial + to create a new Spring Boot app connected to Azure Cosmos DB. +

+
+
+
+ + + + diff --git a/test/CORSBypass.ts b/test/CORSBypass.ts new file mode 100644 index 000000000..be52c95fa --- /dev/null +++ b/test/CORSBypass.ts @@ -0,0 +1,23 @@ +import { Page } from "@playwright/test"; + +export async function setupCORSBypass(page: Page) { + await page.route("**/api/mongo/explorer{,/**}", async (route) => { + const response = await route.fetch({ + headers: { + ...route.request().headers(), + }, + }); + + await route.fulfill({ + status: response.status(), + headers: { + ...response.headers(), + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "*", + }, + body: await response.body(), + }); + }); +} diff --git a/test/fx.ts b/test/fx.ts index 30c73e397..6d7263a8a 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -1,4 +1,4 @@ -import { AzureCliCredential } from "@azure/identity"; +import { DefaultAzureCredential } from "@azure/identity"; import { Frame, Locator, Page, expect } from "@playwright/test"; import crypto from "crypto"; @@ -20,8 +20,8 @@ export function generateUniqueName(baseName, options?: TestNameOptions): string return `${prefix}${baseName}${crypto.randomBytes(length).toString("hex")}${suffix}`; } -export function getAzureCLICredentials(): AzureCliCredential { - return new AzureCliCredential(); +export function getAzureCLICredentials(): DefaultAzureCredential { + return new DefaultAzureCredential(); } export async function getAzureCLICredentialsToken(): Promise { @@ -223,6 +223,9 @@ export class DocumentsTab { documentsListPane: Locator; documentResultsPane: Locator; resultsEditor: Editor; + loadMoreButton: Locator; + filterInput: Locator; + filterButton: Locator; constructor( public frame: Frame, @@ -234,6 +237,13 @@ export class DocumentsTab { this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane"); this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane"); this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded")); + this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore"); + this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput"); + this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter"); + } + + async setFilter(text: string) { + await this.filterInput.fill(text); } } diff --git a/test/mongo/document.spec.ts b/test/mongo/document.spec.ts index 3030d5259..b6703c49a 100644 --- a/test/mongo/document.spec.ts +++ b/test/mongo/document.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; +import { setupCORSBypass } from "../CORSBypass"; import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; import { retry, serializeMongoToJson, setPartitionKeys } from "../testData"; import { documentTestCases } from "./testCases"; @@ -9,7 +10,9 @@ let documentsTab: DocumentsTab = null!; for (const { name, databaseId, containerId, documents } of documentTestCases) { test.describe(`Test MongoRU Documents with ${name}`, () => { + // test.skip(true, "Temporarily disabling all tests in this spec file"); test.beforeEach("Open documents tab", async ({ page }) => { + await setupCORSBypass(page); explorer = await DataExplorer.open(page, TestAccount.MongoReadonly); const containerNode = await explorer.waitForContainerNode(databaseId, containerId); @@ -24,6 +27,9 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { await documentsTab.documentsListPane.waitFor(); await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); }); + test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: "ignoreErrors" }); + }); for (const document of documents) { const { documentId: docId, partitionKeys } = document; @@ -67,8 +73,12 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { await documentsTab.resultsEditor.setText(JSON.stringify(newDocument)); const saveButton = await explorer.waitForCommandBarButton("Save", 5000); await saveButton.click({ timeout: 5000 }); + await expect(saveButton).toBeHidden({ timeout: 5000 }); }, 3); + await documentsTab.setFilter(`{_id: "${newDocumentId}"}`); + await documentsTab.filterButton.click(); + const newSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0); await newSpan.waitFor(); await newSpan.click(); diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts index 74e3f5da1..95cdd112a 100644 --- a/test/sql/document.spec.ts +++ b/test/sql/document.spec.ts @@ -9,6 +9,7 @@ let documentsTab: DocumentsTab = null!; for (const { name, databaseId, containerId, documents } of documentTestCases) { test.describe(`Test SQL Documents with ${name}`, () => { + // test.skip(true, "Temporarily disabling all tests in this spec file"); test.beforeEach("Open documents tab", async ({ page }) => { explorer = await DataExplorer.open(page, TestAccount.SQLReadOnly); @@ -26,7 +27,7 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { }); for (const document of documents) { - const { documentId: docId, partitionKeys } = document; + const { documentId: docId, partitionKeys, skipCreateDelete } = document; test.describe(`Document ID: ${docId}`, () => { test(`should load and view document ${docId}`, async () => { const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); @@ -41,7 +42,9 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { expect(resultText).not.toBeNull(); expect(resultData?.id).toEqual(docId); }); - test(`should be able to create and delete new document from ${docId}`, async ({ page }) => { + + const testOrSkip = skipCreateDelete ? test.skip : test; + testOrSkip(`should be able to create and delete new document from ${docId}`, async ({ page }) => { const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); await span.waitFor(); await expect(span).toBeVisible(); @@ -50,10 +53,6 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { let newDocumentId; await page.waitForTimeout(5000); await retry(async () => { - // const discardButton = await explorer.waitForCommandBarButton("Discard", 5000); - // if (await discardButton.isEnabled()) { - // await discardButton.click(); - // } const newDocumentButton = await explorer.waitForCommandBarButton("New Item", 5000); await expect(newDocumentButton).toBeVisible(); await expect(newDocumentButton).toBeEnabled(); @@ -71,10 +70,15 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { await documentsTab.resultsEditor.setText(JSON.stringify(newDocument)); const saveButton = await explorer.waitForCommandBarButton("Save", 5000); await saveButton.click({ timeout: 5000 }); + await expect(saveButton).toBeHidden({ timeout: 5000 }); }, 3); + await documentsTab.setFilter(`WHERE c.id = "${newDocumentId}"`); + await documentsTab.filterButton.click(); + const newSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0); await newSpan.waitFor(); + await newSpan.click(); await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); diff --git a/test/sql/testCases.ts b/test/sql/testCases.ts index 8c4c3178f..4398c93eb 100644 --- a/test/sql/testCases.ts +++ b/test/sql/testCases.ts @@ -5,7 +5,24 @@ export const documentTestCases: DocumentTestCase[] = [ name: "System Partition Key", databaseId: "e2etests-sql-readonly", containerId: "systemPartitionKey", - documents: [{ documentId: "systempartition", partitionKeys: [] }], + documents: [ + { + documentId: "systempartition", + partitionKeys: [{ key: "/_partitionKey", value: "partitionKey" }], + skipCreateDelete: true, + }, + { + documentId: "systempartition_empty", + partitionKeys: [{ key: "/_partitionKey", value: "" }], + skipCreateDelete: true, + }, + { + documentId: "systempartition_null", + partitionKeys: [{ key: "/_partitionKey", value: null }], + skipCreateDelete: true, + }, + { documentId: "systempartition_missing", partitionKeys: [] }, + ], }, { name: "Single Partition Key", diff --git a/test/testData.ts b/test/testData.ts index 3af3c903c..9027a9721 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -1,13 +1,16 @@ +import crypto from "crypto"; + import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; import { BulkOperationType, Container, CosmosClient, Database, JSONObject } from "@azure/cosmos"; -import crypto from "crypto"; +import { AzureIdentityCredentialAdapter } from "@azure/ms-rest-js"; + import { - TestAccount, generateUniqueName, getAccountName, getAzureCLICredentials, resourceGroupName, subscriptionId, + TestAccount, } from "./fx"; export interface TestItem { @@ -26,6 +29,7 @@ export interface DocumentTestCase { export interface TestDocument { documentId: string; partitionKeys?: PartitionKey[]; + skipCreateDelete?: boolean; } export interface PartitionKey { @@ -74,7 +78,8 @@ export async function createTestSQLContainer(includeTestData?: boolean) { const databaseId = generateUniqueName("db"); const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique const credentials = getAzureCLICredentials(); - const armClient = new CosmosDBManagementClient(credentials, subscriptionId); + const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); + const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); const accountName = getAccountName(TestAccount.SQL); const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);