Compare commits

..

63 Commits

Author SHA1 Message Date
Asier Isayas
30e106f600 clean up tests 2025-12-05 10:00:17 -08:00
Asier Isayas
383da73c52 Remove Synapse from command bar and container create 2025-12-05 09:35:35 -08:00
Laurent Nguyen
ca858c08fb Enhance accessibility by including description in aria-label for button component in Fabric Home (#2272)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-12-05 17:39:18 +01:00
BChoudhury-ms
fa18b85364 copy job process performance enhancement (#2273) 2025-12-05 11:49:25 +05:30
sunghyunkang1111
d060f22357 Added minimum RU when creating container (#2268)
* Added minimum RU when creating container

* fix data test id

* Update test snap
2025-12-04 10:31:19 -08:00
BChoudhury-ms
9a6f090374 Refactor Container Copy Permissions Screen: Group-based Validation and Improved Loading UX (#2269)
* grouped permissions and added styles

* Adding loading overlay for the permission sections
2025-12-03 07:43:13 +05:30
BChoudhury-ms
63cddeb4b8 Integrate container creation screen to copy job flow (#2265) 2025-11-27 13:19:50 +05:30
BChoudhury-ms
bb0bbd8a6e show default copy job name (#2266) 2025-11-27 10:34:08 +05:30
asier-isayas
a33429fd85 Add Session Id (#2263)
* adding sessionId to UserContext

* add session id

* add session id to settings pane and fix npm run compile

* Add conditional for Portal

* set default session id on userContext init

* fix tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-11-26 10:07:18 -08:00
BChoudhury-ms
784dadce30 set intra-account copy as the default one (#2267) 2025-11-26 10:06:45 -08:00
vchske
490309b403 Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding (#2254)
* Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding.

* Changed substringUtf method to be more accurate and added comments
2025-11-24 12:36:03 -08:00
sunghyunkang1111
0fac59967a Fix mongo database name handling (#2262) 2025-11-20 10:23:24 -08:00
Laurent Nguyen
c72d921866 fix: for fabric, don't display Querying offer for collection. (#2259)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-11-20 15:02:32 +01:00
BChoudhury-ms
125b1c86b7 Refactor Container Copy Jobs for Intra-account copy and Online operations (#2258)
* fix: for intra-account copy, validation screen should not visible

* fix: handle online operations using a button instead manual CLI commands

* reset validation cache on leaving of permission screen

* update same account logic

* fix: update job action menu list and permission screen messages

* uplift error handling to context level

* use of logError instead of console.error
2025-11-19 22:41:13 +05:30
Laurent Nguyen
beccab02e7 fix: error handling: better handle error.message undefined or '' case. (#2253)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-11-12 09:06:12 +01:00
sakshigupta12feb
a2e90b3a38 Removed unused old code from DE (#2251)
Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-11 20:08:45 +05:30
vchske
33a7412cf3 Removed broken feature to display PR url in DE console (#2249) 2025-11-07 12:36:03 -08:00
Nishtha Ahuja
6b150dbfa0 Revert "Index Advisor Tab on Execute Query (#2177)" (#2244)
This reverts commit abf4b3bd0f.

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-11-06 20:17:17 +05:30
vchske
bbdf0ce57e Updating Cosmos DB JS SDK to 4.7 (#2243) 2025-11-05 11:18:03 -08:00
BChoudhury-ms
2417da152d Container Copy Job implementation for SQL accounts (#2241)
* Initial dev for container copy

* remove padding from label

* Added Copy Job prerequisites screen

* Added hooks to evaluate reader role access

* added copyjob pre-requsite screen along with it's validations

* Added monitor copy job list screen

* added copy job list refresh and reset functionality

* remove arm token dependency

* fetch account details from account id instead of context

* Fix lint & typescript checks

* show copyjob screen from portal navigation

* adding copy job details screen

* remove duplicate code & show sql accounts only

* ui fixes for list job page

* pending icon

* copy job details screen ui

* reset .vscode/settings.json

* Fixed existing UTs

* disabling action buttons until it's in progress

* fixed formatting

* Adding loader on submit button and show job creation errors in the panel itself

* updating disabling action menu item logic

* added custom pager

* fix lint and ts errors

* updating file names and removing comments

* remove comments

* modularize the arom common code

* Adding content and removing tooltip

* updating job details screen

* updating online copy enabled screen

* Adding below changes
- Don't show permission screen for same account in offline mode
- Don't show identity permissions for same account in online mode
- Show error message if selected containers are identical
- Update abort signal messages

* added feedback code from explorer

* Add tooltips and long polling
- Added tooltips to permission sections
- Implemented long polling for PITR and online copy enabled sections
- Long polling automatically stops after 15 minutes
- After polling ends, a refresh button will be displayed

---------

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-11-05 22:54:00 +05:30
sakshigupta12feb
3718f5a16a Updated document dB text changes (#2223)
* updated documentdb text changes

* updated documentdb text changes

* updated shall value

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-05 11:49:17 +05:30
Mark Brown
08f55ded3d Fabric datasets update (#2242)
* update fabric datasets.

* fix non unicode characters in datasets

* Fix merge issue.

---------

Co-authored-by: Jade Welton <jawelton@microsoft.com>
2025-11-04 06:29:32 -08:00
sakshigupta12feb
74cd4b2ff4 fixed the issue of ddm (#2239)
* fixed the issue of ddm

* fixed the ddm issue

* updated test and ploicyvaluebtdefault as true

* fixed test

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-03 23:01:31 +05:30
Mark Brown
27e07bcd01 fix: Update fabric datasets. (#2238) 2025-10-31 15:28:19 +01:00
sakshigupta12feb
18ecaaba78 DDM Updated the validation logic as per BE values (policyFormatVersion field is removed from BE) (#2237)
* updated the validation logic as per BE values(policyFormatVersion will be removed from BE)

* removed field

* updated all test

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-10-28 19:11:12 +05:30
sakshigupta12feb
0578910b9e DDM in DE for NOSQL (#2224)
* ddm for DE for noSQL

* ddm for DE for noSQL

* ddm for DE for noSQL

* ddm fix for the default case and test fix

* formatting issue

* updated the text change

* added validation errors

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-10-27 19:37:40 +05:30
asier-isayas
ff1eb6a78e Add French, German, and Spanish to Full Text Search (Update container only) (#2228)
* Add multiple languages for Full Text Search Policy

* fix tests

* show multiple languages for multi language support enabled accounts

* addressed comments

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-10-24 12:15:12 -07:00
Laurent Nguyen
31ec3c08bc fix: Add new sample data, update sample container create (#2230)
* add new sample data, update sample container create

* updated data sets

* another fix

* Refactor sample file-specific settings

---------

Co-authored-by: Mark Brown <mjbrown@microsoft.com>
2025-10-24 14:20:24 +02:00
archie-agarwal
abf4b3bd0f Index Advisor Tab on Execute Query (#2177)
Index Advisor
2025-10-24 17:11:59 +05:30
sunghyunkang1111
d0d615a85a Added infobox to advanced settings (#2231)
* Added infobox to advanced settings

* Update tooltip message and test snap
2025-10-21 13:49:52 -05:00
Dmitry Shilov
2996120235 fix: Clean file input after uploading files (#2189)
* fix: Clean file input after uploading files

- Enhance file upload component to trigger re-renders on state changes

* fix: Clean file input after uploading files

- Enhance file upload component to trigger re-renders on state changes
2025-10-20 21:49:41 +02:00
sunghyunkang1111
3cd6d5a65d Added ignore partition key option (#2227)
* Added ignore partition key option

* fix unit test

* fix unit test

* Fix Unit Test
2025-10-16 16:45:50 -05:00
bogercraig
d924824536 Updating allowed endpoint list first from server for running in non-prod environments. (#2222) 2025-10-06 09:58:45 -07:00
Laurent Nguyen
cd27814fad feat: New Fabric sample datasets (#2219)
* add two new fabric sample datasets.

* Update Fabric Home with two sample datasets. One regular and one for vector search.

* Update specs for sample data container

* Add telemetry instead of console log

* Add sampleDataFile to telemetry when importing sample data

---------

Co-authored-by: Mark Brown <mjbrown@microsoft.com>
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-10-03 17:31:05 +02:00
Laurent Nguyen
909957a9a1 feat: Send message to Fabric when container is updated (via settings, created or deleted) (#2221)
* Send message to Fabric when container is updated (via settings, created or deleted).

* Fix format

---------

Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-10-02 18:02:32 +02:00
jawelton74
569e5ed1fc Support RBAC in E2E tests for Mongo & Cassandra (#2220)
* Add E2E test changes to support RBAC for Mongo and Cassandra.

* Uncomment Mongo changes.

* Be more selective with which tokens are passed to DE for each test.
2025-10-01 08:54:43 -07:00
jawelton74
a5c3e6bea0 Preview site - update Node and dependencies. (#2218)
* Update to Node 20.

* Update vulnerable dependencies.
2025-09-29 06:17:29 -07:00
BChoudhury-ms
76e63818d3 Enable RBAC support for MongoDB and Cassandra APIs (#2198)
* enable RBAC support for Mongo & Cassandra API

* fix formatting issue

* Handling AAD integration for Mongo Shell

* remove empty aadToken error

* fix formatting issue

* added environment specific scope endpoints
2025-09-19 01:25:35 +05:30
Nishtha Ahuja
cfb5db4df6 Removed screenshot for mongo cloudshell (#2211)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-09-16 12:16:19 +05:30
Dmitry Shilov
922ca5c523 chore: Update help link in FabricHome component to point to the new documentation (#2206) 2025-09-04 11:58:07 +02:00
Dmitry Shilov
bafe002fa3 chore: Enhance accessibility (#2208)
- Add tabIndex to button
- Add aria attributes to icons and headings
2025-09-04 11:39:11 +02:00
vchske
0817acf404 Commenting or deleting UI references to Query Advisor (#2209)
* Commenting or deleting UI references to Query Advisor

* Removing (commenting out) QueryTabComponent from two views

* Added new splash screen button, commented out copilot prompt bar

* Fixing unit test
2025-08-28 15:47:29 -07:00
asier-isayas
8e2c46301d Allow Mongo users to change thee Guid Representation when conducting CRUD operations for documents (#2204)
* mongo guid representation

* format

* fix return type

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-08-18 12:30:04 -07:00
BChoudhury-ms
012d043c78 Fix CloudShell terminal hanging for Mongo and Cassandra shells due to missing updateTerminalData method (#2199) 2025-08-13 13:02:27 -07:00
Mike Krüger
3afd74a957 Fix faifax default cloud shell region. (#2201) 2025-08-13 11:25:18 -07:00
jawelton74
0ef4399ba4 Support data plane RBAC for E2E tests. (#2176)
* Acquire token for NoSQL account prior to running tests.

* Change client id to user assigned managed identity.

* Change to use managed identity. Add token variables for gremlin and
tables.

* Add RBAC details to test README.

* Add token for SQL readonly database. Skip resource token tests when RBAC
enabled.

* Use hardcoded account name for sql readonly.

* Use specific tag for sql readonly.

* Remove comment.
2025-08-05 10:59:57 -07:00
BChoudhury-ms
870863a723 Disabling telemetry for mongo shell (#2195)
* Disabling telemetry for mongo shell

* fix formatting issues

* removed empty spaces from terminal and segregated disableTelemetry command
2025-07-31 10:03:09 +05:30
JustinKol
e3815734db Min/Max UX changes for Scale & Settings tab (#2192)
* master pull

* changed min max text boxes for autoscale

* test update

* Update .npmrc

* Update settings.json

* Prettier run
2025-07-28 13:24:19 -04:00
BChoudhury-ms
5ea78f9abf Add VCore MongoDB support for VS Code extension integration (#2181)
* support for vCore mongodb support for vscode extension

* added comment for future referance on the double encoded connection string
2025-07-28 22:52:23 +05:30
sindhuba
8a56214ec2 Upgrade SDK version to 4.5.0 (#2194) 2025-07-21 12:44:42 -07:00
Laurent Nguyen
e3ae006100 feat: Disable few checks if Fabric native (#2188)
* Disable few checks if Fabric native

* Fix typo in error message
2025-07-17 08:47:56 +02:00
asier-isayas
589b61afaf Upgrade Cosmos SDK to v4.4.0 (#2187)
* Upgrade Cosmos SDK to v4.4.0

* fix unit tests

* explain crypto.subtle

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-07-15 11:28:28 -07:00
Laurent Nguyen
eb3f6bc93f Fabric: set default throughput for new container to 5k (#2190) 2025-07-15 20:22:06 +02:00
bogercraig
6ec909a97b Use Config.json to Set Environment Specific AAD Auth Settings (#2184)
* Force hosted explorer to load config.json before calling useAADAuth.

* Ensure AAD endpoint from config.json loads before useAADAuth.

* Fix immediate linting errors and warnings.

* Remove separate spinner for waiting on config to load.

* Simplifying auth and reintroducing "No tokens for given scope, and no authorization code was passed to acquireToken." error.  Blocking on login if incorrect AAD_ENDPOINT provided.

* Fix linting errors.

* Add error handling to prevent unhandled errors thrown when login prompt is cancelled prematurely.
2025-07-11 15:34:10 -07:00
asier-isayas
08a51ca6b1 Remove unneeded and misleading console log (#2186)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-07-10 08:58:11 -07:00
jawelton74
30a3b5c7a4 Support data plane RBAC for Gremlin API (#2182)
* Refactor logic for determining if we should use data plane RBAC to a
common function.

* Support RBAC for gremlin API.

* Refactor to use common function.

* Fix unit tests.

* Move test function inside test scope.

* Minor clean ups.

* Reinstate utf8ToB64 function in case this breaks a corner case.
2025-07-09 16:23:09 -07:00
jawelton74
f370507a27 Refactor logic for determining if we should use data plane RBAC (#2180)
* Refactor logic for determining if we should use data plane RBAC to a
common function.

* Move test function into test scope.
2025-07-08 11:41:43 -07:00
sunghyunkang1111
e0edaf405c MSRC fixes for testExplorer and HeatMap (#2183)
* MSRC fixes for testExplorer and HeatMap

* MSRC fixes for testExplorer and HeatMap
2025-07-07 11:00:29 -05:00
Sourabh Jain
f8231600d6 DB Shell: Fix User Consent Message as per Privacy Guidlines and enable this for customers (#2173)
* Fix user Consent msg and fix issues

* fix format

* test fix

* fix snap file
2025-07-07 21:13:34 +05:30
asier-isayas
45c8d70c77 do not set disableNonStreamingOrderByQuery flag (#2179)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-06-26 07:43:55 -07:00
bogercraig
70d7ee755b Add Additional Config from Config.json and Clean Up Unused Config (#2178)
* Cleaning up unused config from portal backend migration.

* Remove config used during backend migration.

* Add backend endpoint override from config.json.

* Add AAD and ARM endpoint overrides from config.json.

* Add GRAPH_ENDPOINT override from config.json.

* Remove unused catalog api version.

* Remove isTerminalEnabled from config.  Cannot find reference in DE, DE Release, or Frontend.

* Fix mongo client unit tests.

* Removing BackendApi from constants since no longer referenced in the codebase.

* Talked with Tara and added the CATALOG_API_VERSION back to the config and substituted out the hard coded string it was intended to replace.

* Include existing portal backend endpoints in default allow list.

* Add localhost:1234 endpoint for Mongo unit tests.

* Removing old backend local test endpoint from backend endpoint list.
2025-06-24 12:50:21 -07:00
Vsevolod Kukol
0a4aed4f47 feat: Enable Container Vector Policy for Fabric Native and adjust throughput/vecotr input visibility logic (#2170) 2025-06-23 17:18:19 +02:00
Dmitry Shilov
a7d007e0dd fix: Partition Keys block for fabric (#2174)
* fix: Partition Keys block for fabric

- Show Partition Keys block for fabric
- Adjust layout and visibility of separators in AddCollectionPanel
2025-06-23 17:18:09 +02:00
217 changed files with 307743 additions and 38001 deletions

View File

@@ -23,8 +23,6 @@ src/Common/MongoUtility.ts
src/Common/NotificationsClientBase.ts
src/Common/QueriesClient.ts
src/Common/Splitter.ts
src/Controls/Heatmap/Heatmap.test.ts
src/Controls/Heatmap/Heatmap.ts
src/Definitions/datatables.d.ts
src/Definitions/gif.d.ts
src/Definitions/globals.d.ts

View File

@@ -177,9 +177,39 @@ jobs:
- name: "Az CLI login"
uses: Azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# We can't use MSAL within playwright so we acquire tokens prior to running the tests
- name: "Acquire RBAC tokens for test accounts"
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
NOSQL_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_TESTACCOUNT_TOKEN"
echo NOSQL_TESTACCOUNT_TOKEN=$NOSQL_TESTACCOUNT_TOKEN >> $GITHUB_ENV
NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN"
echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN"
echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
- name: Upload blob report to GitHub Actions Artifacts

View File

@@ -27,7 +27,7 @@ jobs:
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

2
.npmrc
View File

@@ -1,4 +1,4 @@
save-exact=true
# Ignore peer dependency conflicts
force=true # TODO: Remove this when we update to React 17 or higher!
force=true # TODO: Remove this when we update to React 17 or higher!

View File

@@ -1,5 +1,4 @@
{
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isTerminalEnabled": true,
"isPhoenixEnabled": true
}
}

View File

@@ -1,5 +1,4 @@
{
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isTerminalEnabled" : false,
"isPhoenixEnabled" : false
}
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isPhoenixEnabled": false
}

1
images/AzureOpenAi.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="uuid-adbdae8e-5a41-46d1-8c18-aa73cdbfee32" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><path d="m0,2.7v12.6c0,1.491,1.209,2.7,2.7,2.7h12.6c1.491,0,2.7-1.209,2.7-2.7V2.7c0-1.491-1.209-2.7-2.7-2.7H2.7C1.209,0,0,1.209,0,2.7ZM10.8,0v3.6c0,3.976,3.224,7.2,7.2,7.2h-3.6c-3.976,0-7.199,3.222-7.2,7.198v-3.598c0-3.976-3.224-7.2-7.2-7.2h3.6c3.976,0,7.2-3.224,7.2-7.2Z" fill="#000000" stroke-width="0" /></svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -0,0 +1,17 @@
<svg width="96" height="104" viewBox="0 0 96 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.2" d="M80.5008 81.2203L41.2637 58.2012L35.7705 61.9941L74.6152 84.6208L80.5008 81.2203Z" fill="#AAAAAA"/>
<path opacity="0.2" d="M60.2283 92.5992L20.9912 69.5801L15.498 73.373L54.3428 95.9997L60.2283 92.5992Z" fill="#AAAAAA"/>
<path d="M63.7596 30.9969L74.8768 37.4057L74.746 82.1359L35.7705 59.7708L35.9013 3.00781L63.7596 19.095V30.9969Z" fill="#C9C9C9"/>
<path d="M35.9014 3.00818L41.0022 0L68.8605 16.0872L63.7597 19.0954L35.9014 3.00818Z" fill="#AAAAAA"/>
<path d="M74.8769 37.4067L79.9777 34.5293L79.8469 79.2596L74.7461 82.2677L74.8769 37.4067Z" fill="#AAAAAA"/>
<path d="M43.4872 42.245L54.6043 48.6537L54.4735 93.384L15.498 71.0188L15.6288 14.2559L43.4872 30.3431V42.245Z" fill="#F4F4F4"/>
<path d="M15.6289 14.2562L20.7297 11.248L48.5881 27.3352L43.4872 30.3434L15.6289 14.2562Z" fill="#DCDCDC"/>
<path d="M54.6044 48.6547L59.7052 45.7773L59.5745 90.5076L54.4736 93.5158L54.6044 48.6547Z" fill="#DCDCDC"/>
<path d="M63.7598 19.0961L68.8606 16.0879L79.9778 34.5293L74.8769 37.4067L63.7598 19.0961Z" fill="#C9C9C9"/>
<path d="M63.7598 19.0957L74.8769 37.4063L63.7598 30.9976V19.0957Z" fill="#DCDCDC"/>
<path d="M43.4873 30.3441L48.5881 27.3359L59.7053 45.7774L54.6045 48.6548L43.4873 30.3441Z" fill="#F4F4F4"/>
<path d="M43.4873 30.3438L54.6045 48.6544L43.4873 42.2457V30.3438Z" fill="#C9C9C9"/>
<path d="M46.8751 52.4595V55.9693L23.2275 42.1367V38.627L46.8751 52.4595Z" fill="#C9C9C9"/>
<path d="M46.8751 59.0658V62.5756L23.2275 48.6914V45.1816L46.8751 59.0658Z" fill="#C9C9C9"/>
<path d="M46.8751 65.3621V68.8719L23.2275 54.9877V51.6328L46.8751 65.3621Z" fill="#C9C9C9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0C8.73438 0 9.44271 0.0960961 10.125 0.288288C10.8073 0.48048 11.4427 0.758091 12.0312 1.12112C12.6198 1.48415 13.1589 1.91124 13.6484 2.4024C14.138 2.89356 14.5573 3.44611 14.9062 4.06006C15.2552 4.67401 15.5234 5.32799 15.7109 6.02202C15.8984 6.71605 15.9948 7.44211 16 8.2002C16 9.08108 15.8698 9.92993 15.6094 10.7467C15.349 11.5636 14.9766 12.3136 14.4922 12.997C14.0078 13.6803 13.4323 14.2783 12.7656 14.7908C12.099 15.3033 11.3542 15.701 10.5312 15.984C10.5156 15.9893 10.4922 15.992 10.4609 15.992C10.4297 15.992 10.4062 15.9947 10.3906 16C10.2656 16 10.1667 15.9626 10.0938 15.8879C10.0208 15.8131 9.98438 15.7144 9.98438 15.5916V14.4705C9.98438 14.1021 9.98698 13.7257 9.99219 13.3413C9.99219 13.0691 9.95312 12.7941 9.875 12.5165C9.79688 12.2389 9.65625 12.0067 9.45312 11.8198C10.0573 11.7504 10.5859 11.625 11.0391 11.4434C11.4922 11.2619 11.8724 11.0057 12.1797 10.6747C12.487 10.3437 12.7161 9.94328 12.8672 9.47347C13.0182 9.00367 13.0964 8.43777 13.1016 7.77578C13.1016 7.35936 13.0339 6.96697 12.8984 6.5986C12.763 6.23023 12.5573 5.88856 12.2812 5.57357C12.3385 5.42409 12.3802 5.26927 12.4062 5.10911C12.4323 4.94895 12.4453 4.78879 12.4453 4.62863C12.4453 4.42042 12.4245 4.21488 12.3828 4.01201C12.3411 3.80914 12.2812 3.60627 12.2031 3.4034C12.1771 3.39273 12.1484 3.38739 12.1172 3.38739C12.0859 3.38739 12.0573 3.38739 12.0312 3.38739C11.8646 3.38739 11.6901 3.41408 11.5078 3.46747C11.3255 3.52085 11.1458 3.59026 10.9688 3.67568C10.7917 3.76109 10.6172 3.85452 10.4453 3.95596C10.2734 4.05739 10.125 4.15349 10 4.24424C9.34896 4.05739 8.68229 3.96396 8 3.96396C7.31771 3.96396 6.65104 4.05739 6 4.24424C5.86979 4.15349 5.72135 4.05739 5.55469 3.95596C5.38802 3.85452 5.21615 3.76376 5.03906 3.68368C4.86198 3.6036 4.67969 3.5342 4.49219 3.47548C4.30469 3.41675 4.13021 3.38739 3.96875 3.38739H3.88281C3.85156 3.38739 3.82292 3.39273 3.79688 3.4034C3.72396 3.60093 3.66667 3.80113 3.625 4.004C3.58333 4.20687 3.5599 4.41508 3.55469 4.62863C3.55469 4.78879 3.56771 4.94895 3.59375 5.10911C3.61979 5.26927 3.66146 5.42409 3.71875 5.57357C3.44271 5.88322 3.23698 6.22222 3.10156 6.59059C2.96615 6.95896 2.89844 7.35402 2.89844 7.77578C2.89844 8.42709 2.97396 8.99032 3.125 9.46547C3.27604 9.94061 3.50521 10.341 3.8125 10.6667C4.11979 10.9923 4.5 11.2513 4.95312 11.4434C5.40625 11.6356 5.9349 11.7638 6.53906 11.8278C6.38802 11.9666 6.27344 12.1321 6.19531 12.3243C6.11719 12.5165 6.0625 12.7167 6.03125 12.9249C5.89062 12.9943 5.74219 13.0477 5.58594 13.0851C5.42969 13.1225 5.27344 13.1411 5.11719 13.1411C4.78385 13.1411 4.50781 13.0611 4.28906 12.9009C4.07031 12.7407 3.875 12.5219 3.70312 12.2442C3.64062 12.1428 3.5651 12.0414 3.47656 11.9399C3.38802 11.8385 3.29167 11.7477 3.1875 11.6677C3.08333 11.5876 2.97135 11.5235 2.85156 11.4755C2.73177 11.4274 2.60677 11.4007 2.47656 11.3954H2.38281C2.34115 11.3954 2.30208 11.4034 2.26562 11.4194C2.22917 11.4354 2.19271 11.4515 2.15625 11.4675C2.11979 11.4835 2.10417 11.5102 2.10938 11.5475C2.10938 11.6116 2.14583 11.673 2.21875 11.7317C2.29167 11.7905 2.35156 11.8385 2.39844 11.8759L2.42188 11.8919C2.53646 11.9826 2.63542 12.0681 2.71875 12.1481C2.80208 12.2282 2.88021 12.3163 2.95312 12.4124C3.02604 12.5085 3.08594 12.6099 3.13281 12.7167C3.17969 12.8235 3.23958 12.9489 3.3125 13.0931C3.48958 13.5095 3.73698 13.8111 4.05469 13.998C4.3724 14.1849 4.75521 14.2809 5.20312 14.2863C5.33854 14.2863 5.47396 14.2783 5.60938 14.2623C5.74479 14.2462 5.88021 14.2222 6.01562 14.1902V15.5836C6.01562 15.7117 5.97917 15.8131 5.90625 15.8879C5.83333 15.9626 5.73177 16 5.60156 16H5.53906C5.51302 16 5.48958 15.9947 5.46875 15.984C4.65104 15.7117 3.90625 15.3193 3.23438 14.8068C2.5625 14.2943 1.98698 13.6937 1.50781 13.005C1.02865 12.3163 0.658854 11.5636 0.398438 10.7467C0.138021 9.92993 0.00520833 9.08108 0 8.2002C0 7.44745 0.09375 6.72139 0.28125 6.02202C0.46875 5.32266 0.739583 4.67134 1.09375 4.06807C1.44792 3.4648 1.86458 2.91225 2.34375 2.41041C2.82292 1.90858 3.36198 1.47881 3.96094 1.12112C4.5599 0.76343 5.19792 0.488488 5.875 0.296296C6.55208 0.104104 7.26042 0.00533867 8 0Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

125
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.3.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -116,6 +116,7 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -391,24 +392,25 @@
"license": "0BSD"
},
"node_modules/@azure/cosmos": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.3.0.tgz",
"integrity": "sha512-0Ls3l1uWBBSphx6YRhnM+w7rSvq8qVugBCdO6kSiNuRYXEf6+YWLjbzz4e7L2kkz/6ScFdZIOJYP+XtkiRYOhA==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.7.0.tgz",
"integrity": "sha512-a8OV7E41u/ZDaaaDAFdqTTiJ7c82jZc/+ot3XzNCIIilR25NBB+1ixzWQOAgP8SHRUIKfaUl6wAPdTuiG9I66A==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.7.1",
"@azure/core-rest-pipeline": "^1.15.1",
"@azure/core-tracing": "^1.1.1",
"@azure/core-util": "^1.8.1",
"@azure/keyvault-keys": "^4.8.0",
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.9.0",
"@azure/core-rest-pipeline": "^1.19.1",
"@azure/core-tracing": "^1.2.0",
"@azure/core-util": "^1.11.0",
"@azure/keyvault-keys": "^4.9.0",
"@azure/logger": "^1.1.4",
"fast-json-stable-stringify": "^2.1.0",
"jsbi": "^4.3.0",
"priorityqueuejs": "^2.0.0",
"semaphore": "^1.1.0",
"tslib": "^2.6.2"
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
},
"node_modules/@azure/cosmos-language-service": {
@@ -438,8 +440,9 @@
}
},
"node_modules/@azure/cosmos/node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/identity": {
"version": "4.5.0",
@@ -624,6 +627,14 @@
}
}
},
"node_modules/@azure/ms-rest-js/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@azure/ms-rest-js/node_modules/xml2js": {
"version": "0.5.0",
"license": "MIT",
@@ -683,6 +694,14 @@
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
@@ -7593,6 +7612,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/commutable/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/connected-components": {
"version": "6.8.2",
"license": "BSD-3-Clause",
@@ -9123,6 +9150,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/fixtures/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/iron-icons": {
"version": "1.0.0",
"license": "BSD-3-Clause",
@@ -9280,6 +9315,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/messaging/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/monaco-editor": {
"version": "3.2.2",
"license": "BSD-3-Clause",
@@ -9395,6 +9438,14 @@
"version": "0.18.1",
"license": "MIT"
},
"node_modules/@nteract/monaco-editor/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/mythic-configuration": {
"version": "1.0.12",
"license": "BSD-3-Clause",
@@ -9663,6 +9714,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/reducers/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/selectors": {
"version": "3.2.0",
"license": "BSD-3-Clause",
@@ -9886,6 +9945,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/types/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@octokit/auth-token": {
"version": "4.0.0",
"license": "MIT",
@@ -26417,6 +26484,15 @@
"xmlbuilder": "^15.1.0"
}
},
"node_modules/jest-trx-results-processor/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/jest-util": {
"version": "24.9.0",
"license": "MIT",
@@ -27178,11 +27254,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsbi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
"integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
},
"node_modules/jsbn": {
"version": "0.1.1",
"license": "MIT"
@@ -33756,6 +33827,15 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"license": "BSD-3-Clause",
@@ -35622,8 +35702,9 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"license": "MIT",
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"bin": {
"uuid": "dist/bin/uuid"
}

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.3.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -46,8 +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",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -111,6 +111,7 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
[defaults]
group = dataexplorer-preview
sku = P1V2
sku = P1v2
appserviceplan = dataexplorer-preview
location = westus2
web = dataexplorer-preview

View File

@@ -7,7 +7,6 @@ const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
const githubPullRequestUrl = "https://github.com/Azure/cosmos-explorer/pull";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
const api = createProxyMiddleware({
@@ -57,11 +56,7 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
fetch(`${githubApiUrl}/pulls/${pr}`)
.then((response) => response.json())
.then(({ head: { ref, sha } }) => {
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
prUrl.hash = ref;
search.set("feature.pr", prUrl.href);
.then(({ head: { sha } }) => {
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
explorer.search = search.toString();

36205
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,18 @@
"description": "",
"main": "index.js",
"scripts": {
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:18-lts\" --sku P1V2",
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:20-lts\" --sku P1V2",
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Microsoft Corporation",
"dependencies": {
"express": "^4.17.1",
"body-parser": "^1.20.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"node": "^18.20.6",
"node-fetch": "^2.6.1"
"node": "^20.19.5",
"node-fetch": "^2.6.1",
"path-to-regexp": "^0.1.12"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,10 @@ export class CapabilityNames {
public static readonly EnableServerless: string = "EnableServerless";
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
public static readonly EnableDataMasking: string = "EnableDataMasking";
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy";
}
export enum CapacityMode {
@@ -138,13 +142,12 @@ export enum MongoBackendEndpointType {
remote,
}
export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
public static readonly SampleData: string = "SampleData";
export class AadScopeEndpoints {
public static readonly Development: string = "https://cosmos.azure.com";
public static readonly MPAC: string = "https://cosmos.azure.com";
public static readonly Prod: string = "https://cosmos.azure.com";
public static readonly Fairfax: string = "https://cosmos.azure.us";
public static readonly Mooncake: string = "https://cosmos.azure.cn";
}
export class PortalBackendEndpoints {
@@ -264,6 +267,7 @@ export class HttpHeaders {
public static activityId: string = "x-ms-activity-id";
public static apiType: string = "x-ms-cosmos-apitype";
public static authorization: string = "authorization";
public static entraIdToken: string = "x-ms-entraid-token";
public static collectionIndexTransformationProgress: string =
"x-ms-documentdb-collection-index-transformation-progress";
public static continuation: string = "x-ms-continuation";
@@ -293,6 +297,7 @@ export class HttpHeaders {
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
public static xAPIKey: string = "X-API-Key";
public static sessionId: string = "x-ms-client-session-id";
}
export class ContentType {
@@ -526,11 +531,6 @@ export class PriorityLevel {
public static readonly Default = "low";
}
export class ariaLabelForLearnMoreLink {
public static readonly AnalyticalStore = "Learn more about analytical store.";
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
}
export class GlobalSecondaryIndexLabels {
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
}
@@ -774,3 +774,10 @@ export const ShortenedQueryCopilotSampleContainerSchema = {
userPrompt: "find all products",
};
export enum MongoGuidRepresentation {
Standard = "Standard",
CSharpLegacy = "CSharpLegacy",
JavaLegacy = "JavaLegacy",
PythonLegacy = "PythonLegacy",
}

View File

@@ -4,12 +4,12 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils";
import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext";
import { FabricArtifactInfo, updateUserContext, userContext } from "../UserContext";
import { isDataplaneRbacSupported } from "../Utils/APITypeUtils";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
@@ -20,8 +20,7 @@ const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo;
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && isDataplaneRbacSupported(userContext.apiType);
if (userContext.features.enableAadDataPlane || dataPlaneRBACOptionEnabled) {
if (useDataplaneRbacAuthorization(userContext)) {
Logger.logInfo(
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
"Explorer/tokenProvider",

View File

@@ -1,7 +1,7 @@
import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "../UserContext";
import { ApiType, userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() {
return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled;
@@ -33,3 +33,33 @@ export function isGlobalSecondaryIndexEnabled(): boolean {
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
);
}
export const getDatabaseEndpoint = (apiType: ApiType): string => {
switch (apiType) {
case "Mongo":
return "mongodbDatabases";
case "Cassandra":
return "cassandraKeyspaces";
case "Gremlin":
return "gremlinDatabases";
case "Tables":
return "tables";
case "SQL":
default:
return "sqlDatabases";
}
};
export const getCollectionEndpoint = (apiType: ApiType): string => {
switch (apiType) {
case "Mongo":
return "collections";
case "Cassandra":
return "tables";
case "Gremlin":
return "graphs";
case "SQL":
default:
return "containers";
}
};

View File

@@ -28,3 +28,39 @@ describe("Environment Utility Test", () => {
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
});
});
describe("normalizeArmEndpoint", () => {
it("should append '/' if not present", () => {
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com")).toBe("https://example.com/");
});
it("should return the same uri if '/' is present at the end", () => {
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com/")).toBe("https://example.com/");
});
it("should handle empty string", () => {
expect(EnvironmentUtility.normalizeArmEndpoint("")).toBe("");
});
});
describe("getEnvironment", () => {
it("should return Prod environment", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Prod);
});
it("should return Fairfax environment", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Fairfax,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Fairfax);
});
it("should return Mooncake environment", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mooncake,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mooncake);
});
});

View File

@@ -1,4 +1,5 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { AadScopeEndpoints, PortalBackendEndpoints } from "Common/Constants";
import * as Logger from "Common/Logger";
import { configContext } from "ConfigContext";
export function normalizeArmEndpoint(uri: string): string {
@@ -27,3 +28,17 @@ export const getEnvironment = (): Environment => {
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
};
export const getEnvironmentScopeEndpoint = (): string => {
const environment = getEnvironment();
const endpoint = AadScopeEndpoints[environment];
if (!endpoint) {
throw new Error("Cannot determine AAD scope endpoint");
}
const hrefEndpoint = new URL(endpoint).href.replace(/\/+$/, "/.default");
Logger.logInfo(
`Using AAD scope endpoint: ${hrefEndpoint}, Environment: ${environment}`,
"EnvironmentUtility/getEnvironmentScopeEndpoint",
);
return hrefEndpoint;
};

View File

@@ -23,7 +23,10 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
};
export const getErrorMessage = (error: string | Error = ""): string => {
const errorMessage = typeof error === "string" ? error : error.message;
let errorMessage = typeof error === "string" ? error : error.message;
if (!errorMessage) {
errorMessage = JSON.stringify(error);
}
return replaceKnownError(errorMessage);
};

View File

@@ -0,0 +1,31 @@
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
import React from "react";
interface LoadingOverlayProps {
isLoading: boolean;
label: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
if (!isLoading) {
return null;
}
return (
<Overlay
styles={{
root: {
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
</Overlay>
);
};
export default LoadingOverlay;

View File

@@ -65,7 +65,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -84,7 +83,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
@@ -101,7 +99,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -120,7 +117,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
@@ -137,7 +133,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -156,7 +151,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
@@ -173,7 +167,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -197,7 +190,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -216,7 +208,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
deleteDocuments(databaseId, collection, [documentId]);
expect(window.fetch).toHaveBeenCalledWith(
@@ -233,7 +224,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
});

View File

@@ -1,4 +1,5 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import { getMongoGuidRepresentation } from "Shared/StorageUtility";
import { AuthType } from "../AuthType";
import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
@@ -6,6 +7,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import { userContext } from "../UserContext";
import { isDataplaneRbacEnabledForProxyApi } from "../Utils/AuthorizationUtils";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes } from "./Constants";
import { MinimalQueryIterator } from "./IteratorUtilities";
@@ -15,13 +17,20 @@ const defaultHeaders = {
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
[CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100",
[CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15",
[HttpHeaders.sessionId]: userContext.sessionId,
};
function authHeaders() {
if (userContext.authType === AuthType.EncryptedToken) {
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
} else {
return { [HttpHeaders.authorization]: userContext.authorizationToken };
const headers: { [key: string]: string } = {
[HttpHeaders.authorization]: userContext.authorizationToken,
};
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
headers[HttpHeaders.entraIdToken] = userContext.aadToken;
}
return headers;
}
}
@@ -139,6 +148,9 @@ export function readDocument(
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
? documentId.partitionKeyProperties?.[0]
: "",
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
@@ -181,6 +193,9 @@ export function createDocument(
partitionKey:
collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
documentContent: JSON.stringify(documentContent),
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
@@ -228,6 +243,9 @@ export function updateDocument(
? documentId.partitionKeyProperties?.[0]
: "",
documentContent,
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
@@ -274,6 +292,9 @@ export function deleteDocuments(
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);

View File

@@ -0,0 +1,13 @@
.pager-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
gap: 16px;
}
.pager-container > div {
display: flex;
gap: 8px;
align-items: center;
}

111
src/Common/Pager/index.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { IconButton, Text } from "@fluentui/react";
import * as React from "react";
import "./Pager.css";
export interface PagerProps {
startIndex: number;
totalCount: number;
pageSize: number;
onLoadPage: (startIndex: number, pageSize: number) => void;
disabled?: boolean;
showFirstLast?: boolean;
showItemCount?: boolean;
className?: string;
}
const iconButtonStyles = {
root: {
backgroundColor: "transparent",
},
rootHovered: {
backgroundColor: "transparent",
},
rootPressed: {
backgroundColor: "transparent",
},
rootDisabled: {
backgroundColor: "transparent",
},
rootFocused: {
backgroundColor: "transparent",
outline: "none",
},
};
const Pager: React.FC<PagerProps> = ({
startIndex,
totalCount,
pageSize,
onLoadPage,
disabled = false,
showFirstLast = true,
showItemCount = true,
className,
}) => {
// Calculate current page and total pages from startIndex
const currentPage = Math.floor(startIndex / pageSize) + 1;
const totalPages = Math.ceil(totalCount / pageSize);
const endIndex = Math.min(startIndex + pageSize, totalCount);
const handleFirstPage = () => onLoadPage(0, pageSize);
const handlePreviousPage = () => onLoadPage(startIndex - pageSize, pageSize);
const handleNextPage = () => onLoadPage(startIndex + pageSize, pageSize);
const handleLastPage = () => onLoadPage((totalPages - 1) * pageSize, pageSize);
if (totalCount === 0) {
return null;
}
return (
<div className={className || "pager-container"}>
{showItemCount && (
<Text>
Showing {startIndex + 1} - {endIndex} of {totalCount} items
</Text>
)}
<div>
{showFirstLast && (
<IconButton
iconProps={{ iconName: "DoubleChevronLeft" }}
title="First page"
ariaLabel="Go to first page"
onClick={handleFirstPage}
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
)}
<IconButton
iconProps={{ iconName: "ChevronLeft" }}
title="Previous page"
ariaLabel="Go to previous page"
onClick={handlePreviousPage}
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
<Text>
Page {currentPage} of {totalPages}
</Text>
<IconButton
iconProps={{ iconName: "ChevronRight" }}
title="Next page"
ariaLabel="Go to next page"
onClick={handleNextPage}
disabled={disabled || currentPage === totalPages}
styles={iconButtonStyles}
/>
{showFirstLast && (
<IconButton
iconProps={{ iconName: "DoubleChevronRight" }}
title="Last page"
ariaLabel="Go to last page"
onClick={handleLastPage}
disabled={disabled || currentPage === totalPages}
styles={iconButtonStyles}
/>
)}
</div>
</div>
);
};
export default Pager;

View File

@@ -0,0 +1,32 @@
import { Shimmer, ShimmerElementType, Stack } from "@fluentui/react";
import * as React from "react";
export interface IndentLevel {
level: number;
width?: string;
}
interface ShimmerTreeProps {
indentLevels: IndentLevel[];
style?: React.CSSProperties;
}
const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => {
const renderShimmers = (indent: IndentLevel) => (
<Shimmer
key={Math.random()}
shimmerElements={[
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` },
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
]}
style={{ marginBottom: 8 }}
/>
);
return (
<Stack tokens={{ childrenGap: 8 }} style={{ width: "50%", ...style }} data-testid="shimmer-stack">
{indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))}
</Stack>
);
};
export default ShimmerTree;

View File

@@ -2,7 +2,6 @@
exports[`getCommonQueryOptions builds the correct default options objects 1`] = `
{
"disableNonStreamingOrderByQuery": true,
"enableQueryControl": false,
"enableScanInQuery": true,
"forceQueryPlan": true,
@@ -14,7 +13,6 @@ exports[`getCommonQueryOptions builds the correct default options objects 1`] =
exports[`getCommonQueryOptions reads from localStorage 1`] = `
{
"disableNonStreamingOrderByQuery": true,
"enableQueryControl": false,
"enableScanInQuery": true,
"forceQueryPlan": true,

View File

@@ -1,4 +1,6 @@
import { ContainerRequest, ContainerResponse, DatabaseRequest, DatabaseResponse, RequestOptions } from "@azure/cosmos";
import { sendMessage } from "Common/MessageHandler";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
@@ -43,6 +45,14 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
}
logConsoleInfo(`Successfully created container ${params.collectionId}`);
if (isFabricNative()) {
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
params: { updateType: "created" },
});
}
return collection;
} catch (error) {
handleError(error, "CreateCollection", `Error while creating container ${params.collectionId}`);
@@ -85,9 +95,6 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr
const resource: ARMTypes.SqlContainerResource = {
id: params.collectionId,
};
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
if (params.indexingPolicy) {
resource.indexingPolicy = params.indexingPolicy;
}
@@ -128,9 +135,6 @@ const createMongoCollection = async (params: DataModels.CreateCollectionParams):
const resource: ARMTypes.MongoDBCollectionResource = {
id: params.collectionId,
};
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
if (params.partitionKey) {
const partitionKeyPath: string = params.partitionKey.paths[0];
resource.shardKey = { [partitionKeyPath]: "Hash" };
@@ -169,9 +173,6 @@ const createCassandraTable = async (params: DataModels.CreateCollectionParams):
const resource: ARMTypes.CassandraTableResource = {
id: params.collectionId,
};
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
const rpPayload: ARMTypes.CassandraTableCreateUpdateParameters = {
properties: {
@@ -272,7 +273,6 @@ const createCollectionWithSDK = async (params: DataModels.CreateCollectionParams
partitionKey: params.partitionKey || undefined,
indexingPolicy: params.indexingPolicy || undefined,
uniqueKeyPolicy: params.uniqueKeyPolicy || undefined,
analyticalStorageTtl: params.analyticalStorageTtl,
vectorEmbeddingPolicy: params.vectorEmbeddingPolicy,
fullTextPolicy: params.fullTextPolicy,
} as ContainerRequest; // TODO: remove cast when https://github.com/Azure/azure-cosmos-js/issues/423 is fixed

View File

@@ -23,9 +23,6 @@ export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIn
if (params.materializedViewDefinition) {
resource.materializedViewDefinition = params.materializedViewDefinition;
}
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
if (params.indexingPolicy) {
resource.indexingPolicy = params.indexingPolicy;
}

View File

@@ -1,3 +1,5 @@
import { sendMessage } from "Common/MessageHandler";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { userContext } from "../../UserContext";
@@ -19,6 +21,11 @@ export async function deleteCollection(databaseId: string, collectionId: string)
await client().database(databaseId).container(collectionId).delete();
}
logConsoleInfo(`Successfully deleted container ${collectionId}`);
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
params: { updateType: "deleted" },
});
} catch (error) {
handleError(error, "DeleteCollection", `Error while deleting container ${collectionId}`);
throw error;

View File

@@ -1,3 +1,4 @@
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { configContext } from "../../ConfigContext";
import { userContext } from "../../UserContext";
@@ -41,7 +42,7 @@ interface MetricsResponse {
}
export const getCollectionUsageSizeInKB = async (databaseName: string, containerName: string): Promise<number> => {
if (userContext.authType !== AuthType.AAD) {
if (userContext.authType !== AuthType.AAD || isFabricNative()) {
return undefined;
}

View File

@@ -1,5 +1,4 @@
import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { Queries } from "../Constants";
import { client } from "../CosmosClient";
@@ -28,6 +27,5 @@ export const getCommonQueryOptions = (options: FeedOptions): FeedOptions => {
Queries.itemsPerPage;
options.enableQueryControl = LocalStorageUtility.getEntryBoolean(StorageKey.QueryControlEnabled);
options.maxDegreeOfParallelism = LocalStorageUtility.getEntryNumber(StorageKey.MaxDegreeOfParellism);
options.disableNonStreamingOrderByQuery = !isVectorSearchEnabled();
return options;
};

View File

@@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
try {
if (
userContext.authType === AuthType.AAD &&

View File

@@ -1,5 +1,6 @@
import { Item, RequestOptions } from "@azure/cosmos";
import { HttpHeaders } from "Common/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { CollectionBase } from "../../Contracts/ViewModels";
import DocumentId from "../../Explorer/Tree/DocumentId";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
@@ -23,10 +24,17 @@ export const updateDocument = async (
[HttpHeaders.partitionKey]: documentId.partitionKeyValue,
}
: {};
// If user has chosen to ignore partition key on update, pass null instead of actual partition key value
const ignorePartitionKeyOnDocumentUpdateFlag = LocalStorageUtility.getEntryBoolean(
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
);
const partitionKey = ignorePartitionKeyOnDocumentUpdateFlag ? undefined : getPartitionKeyValue(documentId);
const response = await client()
.database(collection.databaseId)
.container(collection.id())
.item(documentId.id(), getPartitionKeyValue(documentId))
.item(documentId.id(), partitionKey)
.replace(newDocument, options);
logConsoleInfo(`Successfully updated ${entityName} ${documentId.id()}`);

View File

@@ -1,21 +1,15 @@
import { CassandraProxyEndpoints, JunoEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import {
BackendApi,
CassandraProxyEndpoints,
JunoEndpoints,
MongoProxyEndpoints,
PortalBackendEndpoints,
} from "Common/Constants";
import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
allowedEmulatorEndpoints,
allowedGraphEndpoints,
allowedHostedExplorerEndpoints,
allowedJunoOrigins,
allowedMsalRedirectEndpoints,
defaultAllowedAadEndpoints,
defaultAllowedArmEndpoints,
defaultAllowedBackendEndpoints,
defaultAllowedCassandraProxyEndpoints,
defaultAllowedGraphEndpoints,
defaultAllowedMongoProxyEndpoints,
validateEndpoint,
} from "Utils/EndpointUtils";
@@ -29,6 +23,8 @@ export enum Platform {
export interface ConfigContext {
platform: Platform;
allowedAadEndpoints: ReadonlyArray<string>;
allowedGraphEndpoints: ReadonlyArray<string>;
allowedArmEndpoints: ReadonlyArray<string>;
allowedBackendEndpoints: ReadonlyArray<string>;
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
@@ -37,10 +33,8 @@ export interface ConfigContext {
gitSha?: string;
proxyPath?: string;
AAD_ENDPOINT: string;
ARM_AUTH_AREA: string;
ARM_ENDPOINT: string;
EMULATOR_ENDPOINT?: string;
ARM_API_VERSION: string;
GRAPH_ENDPOINT: string;
GRAPH_API_VERSION: string;
// This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP
@@ -50,27 +44,24 @@ export interface ConfigContext {
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
PORTAL_BACKEND_ENDPOINT: string;
NEW_BACKEND_APIS?: BackendApi[];
MONGO_PROXY_ENDPOINT: string;
CASSANDRA_PROXY_ENDPOINT: string;
NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string;
JUNO_ENDPOINT: string;
GITHUB_CLIENT_ID: string;
GITHUB_TEST_ENV_CLIENT_ID: string;
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
isTerminalEnabled: boolean;
isPhoenixEnabled: boolean;
hostedExplorerURL: string;
armAPIVersion?: string;
msalRedirectURI?: string;
globallyEnabledCassandraAPIs?: string[];
globallyEnabledMongoAPIs?: string[];
}
// Default configuration
let configContext: Readonly<ConfigContext> = {
platform: Platform.Portal,
allowedAadEndpoints: defaultAllowedAadEndpoints,
allowedGraphEndpoints: defaultAllowedGraphEndpoints,
allowedArmEndpoints: defaultAllowedArmEndpoints,
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
allowedCassandraProxyEndpoints: defaultAllowedCassandraProxyEndpoints,
@@ -85,17 +76,12 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
`^https:\\/\\/.*\\.powerbi\\.com$`,
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
AAD_ENDPOINT: "https://login.microsoftonline.com/",
ARM_AUTH_AREA: "https://management.azure.com/",
ARM_ENDPOINT: "https://management.azure.com/",
ARM_API_VERSION: "2016-06-01",
GRAPH_ENDPOINT: "https://graph.microsoft.com",
GRAPH_API_VERSION: "1.6",
CATALOG_ENDPOINT: "https://catalogapi.azure.com/",
@@ -109,11 +95,7 @@ let configContext: Readonly<ConfigContext> = {
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
isTerminalEnabled: false,
isPhoenixEnabled: false,
globallyEnabledCassandraAPIs: [],
globallyEnabledMongoAPIs: [],
};
export function resetConfigContext(): void {
@@ -128,19 +110,38 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
return;
}
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints || defaultAllowedArmEndpoints)) {
delete newContext.ARM_ENDPOINT;
if (newContext.allowedAadEndpoints) {
Object.assign(configContext, { allowedAadEndpoints: newContext.allowedAadEndpoints });
}
if (newContext.allowedArmEndpoints) {
Object.assign(configContext, { allowedArmEndpoints: newContext.allowedArmEndpoints });
}
if (newContext.allowedGraphEndpoints) {
Object.assign(configContext, { allowedGraphEndpoints: newContext.allowedGraphEndpoints });
}
if (newContext.allowedBackendEndpoints) {
Object.assign(configContext, { allowedBackendEndpoints: newContext.allowedBackendEndpoints });
}
if (newContext.allowedMongoProxyEndpoints) {
Object.assign(configContext, { allowedMongoProxyEndpoints: newContext.allowedMongoProxyEndpoints });
}
if (newContext.allowedCassandraProxyEndpoints) {
Object.assign(configContext, { allowedCassandraProxyEndpoints: newContext.allowedCassandraProxyEndpoints });
}
if (!validateEndpoint(newContext.AAD_ENDPOINT, allowedAadEndpoints)) {
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints)) {
delete newContext.AAD_ENDPOINT;
}
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints)) {
delete newContext.ARM_ENDPOINT;
}
if (!validateEndpoint(newContext.EMULATOR_ENDPOINT, allowedEmulatorEndpoints)) {
delete newContext.EMULATOR_ENDPOINT;
}
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, allowedGraphEndpoints)) {
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints)) {
delete newContext.GRAPH_ENDPOINT;
}
@@ -148,21 +149,15 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.ARCADIA_ENDPOINT;
}
if (
!validateEndpoint(
newContext.MONGO_PROXY_ENDPOINT,
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
)
) {
if (!validateEndpoint(newContext.PORTAL_BACKEND_ENDPOINT, configContext.allowedBackendEndpoints)) {
delete newContext.PORTAL_BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, configContext.allowedMongoProxyEndpoints)) {
delete newContext.MONGO_PROXY_ENDPOINT;
}
if (
!validateEndpoint(
newContext.CASSANDRA_PROXY_ENDPOINT,
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
)
) {
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, configContext.allowedCassandraProxyEndpoints)) {
delete newContext.CASSANDRA_PROXY_ENDPOINT;
}

View File

@@ -1,4 +1,5 @@
import { FabricMessageTypes } from "./FabricMessageTypes";
import { MessageTypes } from "./MessageTypes";
// This is the current version of these messages
export const DATA_EXPLORER_RPC_VERSION = "3";
@@ -19,9 +20,32 @@ export type DataExploreMessageV3 =
type: FabricMessageTypes.GetAllResourceTokens;
id: string;
}
| {
type: FabricMessageTypes.GetAccessToken;
id: string;
}
| {
type: MessageTypes.TelemetryInfo;
data: {
action: string;
actionModifier: string;
data: unknown;
timestamp: number;
};
}
| {
type: FabricMessageTypes.OpenSettings;
settingsId: string;
params: [{ settingsId?: "About" | "Connection" }];
}
| {
type: FabricMessageTypes.RestoreContainer;
params: [];
}
| {
type: FabricMessageTypes.ContainerUpdated;
params: {
updateType: "created" | "deleted" | "settings";
};
};
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";

View File

@@ -10,15 +10,34 @@ export interface ArmEntity {
resourceGroup?: string;
}
export interface DatabaseAccountUserAssignedIdentity {
[key: string]: {
principalId: string;
clientId: string;
};
}
export interface DatabaseAccountIdentity {
type: string;
principalId?: string;
tenantId?: string;
userAssignedIdentities?: DatabaseAccountUserAssignedIdentity;
}
export interface DatabaseAccount extends ArmEntity {
properties: DatabaseAccountExtendedProperties;
systemData?: DatabaseAccountSystemData;
identity?: DatabaseAccountIdentity | null;
}
export interface DatabaseAccountSystemData {
createdAt: string;
}
export interface DatabaseAccountBackupPolicy {
type: string;
}
export interface DatabaseAccountExtendedProperties {
documentEndpoint?: string;
disableLocalAuth?: boolean;
@@ -29,6 +48,8 @@ export interface DatabaseAccountExtendedProperties {
capabilities?: Capability[];
enableMultipleWriteLocations?: boolean;
mongoEndpoint?: string;
backupPolicy?: DatabaseAccountBackupPolicy;
defaultIdentity?: string;
readLocations?: DatabaseAccountResponseLocation[];
writeLocations?: DatabaseAccountResponseLocation[];
enableFreeTier?: boolean;
@@ -44,6 +65,7 @@ export interface DatabaseAccountExtendedProperties {
publicNetworkAccess?: string;
enablePriorityBasedExecution?: boolean;
vcoreMongoEndpoint?: string;
enableAllVersionsAndDeletesChangeFeed?: boolean;
}
export interface DatabaseAccountResponseLocation {
@@ -101,6 +123,24 @@ export interface Subscription {
authorizationSource?: string;
}
export interface DatabaseModel extends ArmEntity {
properties: DatabaseGetProperties;
}
export interface DatabaseGetProperties {
resource: DatabaseResource & ExtendedResourceProperties;
}
export interface DatabaseResource {
id: string;
}
export interface ExtendedResourceProperties {
readonly _rid?: string;
readonly _self?: string;
readonly _ts?: number;
readonly _etag?: string;
}
export interface SubscriptionPolicies {
locationPlacementId: string;
quotaId: string;
@@ -163,6 +203,7 @@ export interface Collection extends Resource {
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
dataMaskingPolicy?: DataMaskingPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
@@ -227,6 +268,17 @@ export interface ComputedProperty {
export type ComputedProperties = ComputedProperty[];
export interface DataMaskingPolicy {
includedPaths: Array<{
path: string;
strategy: string;
startPosition: number;
length: number;
}>;
excludedPaths: string[];
isPolicyEnabled: boolean;
}
export interface MaterializedView {
id: string;
_rid: string;
@@ -364,7 +416,6 @@ export interface CreateCollectionParamsBase {
databaseId: string;
databaseLevelThroughput: boolean;
offerThroughput?: number;
analyticalStorageTtl?: number;
autoPilotMaxThroughput?: number;
indexingPolicy?: IndexingPolicy;
partitionKey?: PartitionKey;

View File

@@ -7,6 +7,8 @@ export enum FabricMessageTypes {
GetAccessToken = "GetAccessToken",
Ready = "Ready",
OpenSettings = "OpenSettings",
RestoreContainer = "RestoreContainer",
ContainerUpdated = "ContainerUpdated",
}
export interface AuthorizationToken {

View File

@@ -49,4 +49,5 @@ export enum MessageTypes {
Ready, // unused. Can be removed if the portal uses the same list of enums.
OpenCESCVAFeedbackBlade,
ActivateTab,
OpenContainerCopyFeedbackBlade,
}

View File

@@ -140,6 +140,7 @@ export interface Collection extends CollectionBase {
requestSchema?: () => void;
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
usageSizeInKB: ko.Observable<number>;
@@ -443,6 +444,9 @@ export interface DataExplorerInputsFrame {
[key: string]: string;
};
feedbackPolicies?: any;
aadToken?: string;
containerCopyEnabled?: boolean;
sessionId?: string;
}
export interface SelfServeFrameInputs {

View File

@@ -1,11 +0,0 @@
<!doctype html>
<html class="no-js" lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="data:," />
</head>
<body>
<div id="heatmap"></div>
</body>
</html>

View File

@@ -1,55 +0,0 @@
@import "../../../less/Common/Constants";
html {
font-family: @DataExplorerFont;
padding: 0px;
margin: 0px;
border: 0px;
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
body {
font-family: @DataExplorerFont;
padding: 0px;
margin: 0px;
border: 0px;
overflow: hidden;
}
#heatmap {
.dark-theme {
color: @BaseLight;
}
.chartTitle {
position: absolute;
top: 5px;
left: 3px;
font-size: 13px;
}
.noDataMessage {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
z-index: 10000;
height: 100%;
width: 100%;
top: 0;
left: 0;
opacity: 0.97;
div {
border-color: rgba(204, 204, 204, 0.8);
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.12);
padding: 15px 10px;
width: calc(55% - 40px);
font-size: 13px;
text-align: center;
border-width: 1px;
border-style: solid;
}
}
}

View File

@@ -1,143 +0,0 @@
import dayjs from "dayjs";
import { handleMessage, Heatmap, isDarkTheme } from "./Heatmap";
import { PortalTheme } from "./HeatmapDatatypes";
describe("The Heatmap Control", () => {
const dataPoints = {
"1": {
"2019-06-19T00:59:10Z": {
"Normalized Throughput": 0.35,
},
"2019-06-19T00:48:10Z": {
"Normalized Throughput": 0.25,
},
},
};
const chartCaptions = {
chartTitle: "chart title",
yAxisTitle: "YAxisTitle",
tooltipText: "Tooltip text",
timeWindow: 123456789,
};
let heatmap: Heatmap;
const theme: PortalTheme = 1;
const divElement = `<div id="${Heatmap.elementId}"></div>`;
describe("drawHeatmap rendering", () => {
beforeEach(() => {
heatmap = new Heatmap(dataPoints, chartCaptions, theme);
document.body.innerHTML = divElement;
});
afterEach(() => {
document.body.innerHTML = ``;
});
it("should call _getChartSettings when drawHeatmap is invoked", () => {
const _getChartSettings = jest.spyOn(heatmap, "_getChartSettings");
heatmap.drawHeatmap();
expect(_getChartSettings).toHaveBeenCalled();
});
it("should call _getLayoutSettings when drawHeatmap is invoked", () => {
const _getLayoutSettings = jest.spyOn(heatmap, "_getLayoutSettings");
heatmap.drawHeatmap();
expect(_getLayoutSettings).toHaveBeenCalled();
});
it("should call _getChartDisplaySettings when drawHeatmap is invoked", () => {
const _getChartDisplaySettings = jest.spyOn(heatmap, "_getChartDisplaySettings");
heatmap.drawHeatmap();
expect(_getChartDisplaySettings).toHaveBeenCalled();
});
it("drawHeatmap should render a Heatmap inside the div element", () => {
heatmap.drawHeatmap();
expect(document.body.innerHTML).not.toEqual(divElement);
});
});
describe("generateMatrixFromMap", () => {
it("should massage input data to match output expected", () => {
expect(heatmap.generateMatrixFromMap(dataPoints).yAxisPoints).toEqual(["1"]);
expect(heatmap.generateMatrixFromMap(dataPoints).dataPoints).toEqual([[0.25, 0.35]]);
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints.length).toEqual(2);
});
it("should output the date format to ISO8601 string format", () => {
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints[0].slice(10, 11)).toEqual("T");
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints[0].slice(-1)).toEqual("Z");
});
it("should convert the time to the user's local time", () => {
if (dayjs().utcOffset()) {
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints).not.toEqual([
"2019-06-19T00:48:10Z",
"2019-06-19T00:59:10Z",
]);
} else {
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints).toEqual([
"2019-06-19T00:48:10Z",
"2019-06-19T00:59:10Z",
]);
}
});
});
describe("isDarkTheme", () => {
it("isDarkTheme should return the correct result", () => {
expect(isDarkTheme(PortalTheme.dark)).toEqual(true);
expect(isDarkTheme(PortalTheme.azure)).not.toEqual(true);
});
});
});
describe("iframe rendering when there is no data", () => {
afterEach(() => {
document.body.innerHTML = ``;
});
it("should show a no data message with a dark theme", () => {
const data = {
data: {
signature: "pcIframe",
data: {
chartData: {},
chartSettings: {},
theme: 4,
},
},
origin: "http://localhost",
};
const divElement = `<div id="${Heatmap.elementId}"></div>`;
document.body.innerHTML = divElement;
handleMessage(data as MessageEvent);
expect(document.body.innerHTML).toContain("dark-theme");
expect(document.body.innerHTML).toContain("noDataMessage");
});
it("should show a no data message with a white theme", () => {
const data = {
data: {
signature: "pcIframe",
data: {
chartData: {},
chartSettings: {},
theme: 2,
},
},
origin: "http://localhost",
};
const divElement = `<div id="${Heatmap.elementId}"></div>`;
document.body.innerHTML = divElement;
handleMessage(data as MessageEvent);
expect(document.body.innerHTML).not.toContain("dark-theme");
expect(document.body.innerHTML).toContain("noDataMessage");
});
});

View File

@@ -1,272 +0,0 @@
import dayjs from "dayjs";
import * as Plotly from "plotly.js-cartesian-dist-min";
import { sendCachedDataMessage, sendReadyMessage } from "../../Common/MessageHandler";
import { StyleConstants } from "../../Common/StyleConstants";
import { MessageTypes } from "../../Contracts/ExplorerContracts";
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
import "./Heatmap.less";
import {
ChartSettings,
DataPayload,
DisplaySettings,
FontSettings,
HeatmapCaptions,
HeatmapData,
LayoutSettings,
PartitionTimeStampToData,
PortalTheme,
} from "./HeatmapDatatypes";
export class Heatmap {
public static readonly elementId: string = "heatmap";
private _chartData: HeatmapData;
private _heatmapCaptions: HeatmapCaptions;
private _theme: PortalTheme;
private _defaultFontColor: string;
constructor(data: DataPayload, heatmapCaptions: HeatmapCaptions, theme: PortalTheme) {
this._theme = theme;
this._defaultFontColor = StyleConstants.BaseDark;
this._setThemeColorForChart();
this._chartData = this.generateMatrixFromMap(data);
this._heatmapCaptions = heatmapCaptions;
}
private _setThemeColorForChart() {
if (isDarkTheme(this._theme)) {
this._defaultFontColor = StyleConstants.BaseLight;
}
}
private _getFontStyles(size: number = StyleConstants.MediumFontSize, color = "#838383"): FontSettings {
return {
family: StyleConstants.DataExplorerFont,
size,
color,
};
}
public generateMatrixFromMap(data: DataPayload): HeatmapData {
// all keys in data payload, sorted...
const rows: string[] = Object.keys(data).sort((a: string, b: string) => {
if (parseInt(a) < parseInt(b)) {
return -1;
} else {
if (parseInt(a) > parseInt(b)) {
return 1;
} else {
return 0;
}
}
});
const output: HeatmapData = {
yAxisPoints: [],
dataPoints: [],
xAxisPoints: Object.keys(data[rows[0]]).sort((a: string, b: string) => {
if (a < b) {
return -1;
} else {
if (a > b) {
return 1;
} else {
return 0;
}
}
}),
};
// go thru all rows and create 2d matrix for heatmap...
for (let i = 0; i < rows.length; i++) {
output.yAxisPoints.push(rows[i]);
const dataPoints: number[] = [];
for (let a = 0; a < output.xAxisPoints.length; a++) {
const row: PartitionTimeStampToData = data[rows[i]];
dataPoints.push(row[output.xAxisPoints[a]]["Normalized Throughput"]);
}
output.dataPoints.push(dataPoints);
}
for (let a = 0; a < output.xAxisPoints.length; a++) {
const dateTime = output.xAxisPoints[a];
// convert to local users timezone...
const day = dayjs(new Date(dateTime)).format("YYYY-MM-DD");
const hour = dayjs(new Date(dateTime)).format("HH:mm:ss");
// coerce to ISOString format since that is what plotly wants...
output.xAxisPoints[a] = `${day}T${hour}Z`;
}
return output;
}
// public for testing purposes
public _getChartSettings(): ChartSettings[] {
return [
{
z: this._chartData.dataPoints,
type: "heatmap",
zmin: 0,
zmid: 50,
zmax: 100,
colorscale: [
[0.0, "#1FD338"],
[0.1, "#1CAD2F"],
[0.2, "#50A527"],
[0.3, "#719F21"],
[0.4, "#95991B"],
[0.5, "#CE8F11"],
[0.6, "#E27F0F"],
[0.7, "#E46612"],
[0.8, "#E64914"],
[0.9, "#B80016"],
[1.0, "#B80016"],
],
name: "",
hovertemplate: this._heatmapCaptions.tooltipText,
colorbar: {
thickness: 15,
outlinewidth: 0,
tickcolor: StyleConstants.BaseDark,
tickfont: this._getFontStyles(10, this._defaultFontColor),
},
y: this._chartData.yAxisPoints,
x: this._chartData.xAxisPoints,
},
];
}
// public for testing purposes
public _getLayoutSettings(): LayoutSettings {
return {
margin: {
l: 40,
r: 10,
b: 35,
t: 30,
pad: 0,
},
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
width: 462,
height: 240,
yaxis: {
title: this._heatmapCaptions.yAxisTitle,
titlefont: this._getFontStyles(11),
autorange: true,
showgrid: false,
zeroline: false,
showline: false,
autotick: true,
fixedrange: true,
ticks: "",
showticklabels: false,
},
xaxis: {
fixedrange: true,
title: "*White area in heatmap indicates there is no available data",
titlefont: this._getFontStyles(11),
autorange: true,
showgrid: false,
zeroline: false,
showline: false,
autotick: true,
tickformat: this._heatmapCaptions.timeWindow > 7 ? "%I:%M %p" : "%b %e",
showticklabels: true,
tickfont: this._getFontStyles(10),
},
title: {
text: this._heatmapCaptions.chartTitle,
x: 0.01,
font: this._getFontStyles(13, this._defaultFontColor),
},
};
}
// public for testing purposes
public _getChartDisplaySettings(): DisplaySettings {
return {
/* heatmap can be fully responsive however the min-height needed in that case is greater than the iframe portal height, hence explicit width + height have been set in _getLayoutSettings
responsive: true,*/
displayModeBar: false,
};
}
public drawHeatmap(): void {
// todo - create random elementId generator so multiple heatmaps can be created - ticket # 431469
Plotly.plot(
Heatmap.elementId,
this._getChartSettings(),
this._getLayoutSettings(),
this._getChartDisplaySettings(),
);
const plotDiv: any = document.getElementById(Heatmap.elementId);
plotDiv.on("plotly_click", (data: any) => {
let timeSelected: string = data.points[0].x;
timeSelected = timeSelected.replace(" ", "T");
timeSelected = `${timeSelected}Z`;
let xAxisIndex = 0;
for (let i = 0; i < this._chartData.xAxisPoints.length; i++) {
if (this._chartData.xAxisPoints[i] === timeSelected) {
xAxisIndex = i;
break;
}
}
const output = [];
for (let i = 0; i < this._chartData.dataPoints.length; i++) {
output.push(this._chartData.dataPoints[i][xAxisIndex]);
}
sendCachedDataMessage(MessageTypes.LogInfo, output);
});
}
}
export function isDarkTheme(theme: PortalTheme) {
return theme === PortalTheme.dark;
}
export function handleMessage(event: MessageEvent) {
if (isInvalidParentFrameOrigin(event)) {
return;
}
if (typeof event.data !== "object" || event.data["signature"] !== "pcIframe") {
return;
}
if (
typeof event.data.data !== "object" ||
!("chartData" in event.data.data) ||
!("chartSettings" in event.data.data)
) {
return;
}
Plotly.purge(Heatmap.elementId);
document.getElementById(Heatmap.elementId)!.innerHTML = "";
const data = event.data.data;
const chartData: DataPayload = data.chartData;
const chartSettings: HeatmapCaptions = data.chartSettings;
const chartTheme: PortalTheme = data.theme;
if (Object.keys(chartData).length) {
new Heatmap(chartData, chartSettings, chartTheme).drawHeatmap();
} else {
const chartTitleElement = document.createElement("div");
chartTitleElement.innerHTML = data.chartSettings.chartTitle;
chartTitleElement.classList.add("chartTitle");
const noDataMessageElement = document.createElement("div");
noDataMessageElement.classList.add("noDataMessage");
const noDataMessageContent = document.createElement("div");
noDataMessageContent.innerHTML = data.errorMessage;
noDataMessageElement.appendChild(noDataMessageContent);
if (isDarkTheme(chartTheme)) {
chartTitleElement.classList.add("dark-theme");
noDataMessageElement.classList.add("dark-theme");
noDataMessageContent.classList.add("dark-theme");
}
document.getElementById(Heatmap.elementId)!.appendChild(chartTitleElement);
document.getElementById(Heatmap.elementId)!.appendChild(noDataMessageElement);
}
}
window.addEventListener("message", handleMessage, false);
sendReadyMessage();

View File

@@ -1,106 +0,0 @@
type dataPoint = string | number;
export interface DataPayload {
[id: string]: PartitionTimeStampToData;
}
export enum PortalTheme {
blue = 1,
azure,
light,
dark,
}
export interface HeatmapData {
yAxisPoints: string[];
xAxisPoints: string[];
dataPoints: dataPoint[][];
}
export interface HeatmapCaptions {
chartTitle: string;
yAxisTitle: string;
tooltipText: string;
timeWindow: number;
}
export interface FontSettings {
family: string;
size: number;
color: string;
}
export interface LayoutSettings {
paper_bgcolor?: string;
plot_bgcolor?: string;
margin?: {
l: number;
r: number;
b: number;
t: number;
pad: number;
};
width?: number;
height?: number;
yaxis?: {
fixedrange: boolean;
title: HeatmapCaptions["yAxisTitle"];
titlefont: FontSettings;
autorange: boolean;
showgrid: boolean;
zeroline: boolean;
showline: boolean;
autotick: boolean;
ticks: "";
showticklabels: boolean;
};
xaxis?: {
fixedrange: boolean;
title: string;
titlefont: FontSettings;
autorange: boolean;
showgrid: boolean;
zeroline: boolean;
showline: boolean;
autotick: boolean;
showticklabels: boolean;
tickformat: string;
tickfont: FontSettings;
};
title?: {
text: HeatmapCaptions["chartTitle"];
x: number;
font?: FontSettings;
};
font?: FontSettings;
}
export interface ChartSettings {
z: HeatmapData["dataPoints"];
type: "heatmap";
zmin: number;
zmid: number;
zmax: number;
colorscale: [number, string][];
name: string;
hovertemplate: HeatmapCaptions["tooltipText"];
colorbar: {
thickness: number;
outlinewidth: number;
tickcolor: string;
tickfont: FontSettings;
};
y: HeatmapData["yAxisPoints"];
x: HeatmapData["xAxisPoints"];
}
export interface DisplaySettings {
displayModeBar: boolean;
responsive?: boolean;
}
export interface PartitionTimeStampToData {
[timeSeriesDates: string]: {
[NormalizedThroughput: string]: number;
};
}

View File

@@ -0,0 +1,208 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { logError } from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import {
cancel,
complete,
create,
listByDatabaseAccount,
pause,
resume,
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
import {
CreateJobRequest,
DataTransferJobGetResults,
} from "../../../Utils/arm/generatedClients/dataTransferService/types";
import ContainerCopyMessages from "../ContainerCopyMessages";
import {
convertTime,
convertToCamelCase,
COSMOS_SQL_COMPONENT,
extractErrorMessage,
formatUTCDateTime,
getAccountDetailsFromResourceId,
isIntraAccountCopy,
} from "../CopyJobUtils";
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
export const openCreateCopyJobPanel = (explorer: Explorer) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.createCopyJobPanelTitle,
<CreateCopyJobScreensProvider explorer={explorer} />,
"650px",
);
};
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
<CopyJobDetails job={job} />,
"650px",
);
};
let copyJobsAbortController: AbortController | null = null;
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
try {
if (copyJobsAbortController) {
copyJobsAbortController.abort();
}
copyJobsAbortController = new AbortController();
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const response = await listByDatabaseAccount(
subscriptionId,
resourceGroup,
accountName,
copyJobsAbortController.signal,
);
const jobs = response.value || [];
if (!Array.isArray(jobs)) {
throw new Error("Invalid migration job status response: Expected an array of jobs.");
}
copyJobsAbortController = null;
const calculateCompletionPercentage = (processed: number, total: number): number => {
if (
typeof processed !== "number" ||
typeof total !== "number" ||
!isFinite(processed) ||
!isFinite(total) ||
total <= 0
) {
return 0;
}
const percentage = Math.round((processed / total) * 100);
return Math.max(0, Math.min(100, percentage));
};
const formattedJobs: CopyJobType[] = jobs
.filter(
(job: DataTransferJobGetResults) =>
job.properties?.source?.component === COSMOS_SQL_COMPONENT &&
job.properties?.destination?.component === COSMOS_SQL_COMPONENT,
)
.sort(
(current: DataTransferJobGetResults, next: DataTransferJobGetResults) =>
new Date(next.properties.lastUpdatedUtcTime).getTime() -
new Date(current.properties.lastUpdatedUtcTime).getTime(),
)
.map((job: DataTransferJobGetResults, index: number) => {
const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime);
return {
ID: (index + 1).toString(),
Mode: job.properties.mode,
Name: job.properties.jobName,
Source: job.properties.source,
Destination: job.properties.destination,
Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"],
CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount),
Duration: convertTime(job.properties.duration),
LastUpdatedTime: dateTimeObj.formattedDateTime,
timestamp: dateTimeObj.timestamp,
Error: job.properties.error ? extractErrorMessage(job.properties.error as unknown as CopyJobErrorType) : null,
} as CopyJobType;
});
return formattedJobs;
} catch (error) {
const errorContent = JSON.stringify(error.content || error.message || error);
if (errorContent.includes("signal is aborted without reason")) {
throw {
message:
"Please wait for the current fetch request to complete. The previous copy job fetch request was aborted.",
};
} else {
throw error;
}
}
};
export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => {
try {
const { source, target, migrationType, jobName } = state;
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const isSameAccount = isIntraAccountCopy(source?.account?.id, target?.account?.id);
const body = {
properties: {
source: {
component: "CosmosDBSql",
...(isSameAccount ? {} : { remoteAccountName: source?.account?.name }),
databaseName: source?.databaseId,
containerName: source?.containerId,
},
destination: {
component: "CosmosDBSql",
databaseName: target?.databaseId,
containerName: target?.containerId,
},
mode: migrationType,
},
} as unknown as CreateJobRequest;
const response = await create(subscriptionId, resourceGroup, accountName, jobName, body);
MonitorCopyJobsRefState.getState().ref?.refreshJobList();
onSuccess();
return response;
} catch (error) {
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
throw error;
}
};
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobGetResults> => {
try {
let updateFn = null;
switch (action.toLowerCase()) {
case CopyJobActions.pause:
updateFn = pause;
break;
case CopyJobActions.resume:
updateFn = resume;
break;
case CopyJobActions.cancel:
updateFn = cancel;
break;
case CopyJobActions.complete:
updateFn = complete;
break;
default:
throw new Error(`Unsupported action: ${action}`);
}
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name);
return response;
} catch (error) {
const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error);
const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
const pattern = new RegExp(`'(${statusList.join("|")})'`, "g");
const normalizedErrorMessage = errorMessage.replace(
pattern,
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
);
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
throw error;
}
};

View File

@@ -0,0 +1,31 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import React from "react";
import { StyleConstants } from "../../../Common/StyleConstants";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
import { ContainerCopyProps } from "../Types/CopyJobTypes";
import { getCommandBarButtons } from "./Utils";
const backgroundColor = StyleConstants.BaseLight;
const rootStyle = {
root: {
backgroundColor: backgroundColor,
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (
<div className="commandBarContainer">
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
styles={rootStyle}
items={controlButtons}
/>
</div>
);
};
export default CopyJobCommandBar;

View File

@@ -0,0 +1,59 @@
import AddIcon from "../../../../images/Add.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
import { configContext, Platform } from "../../../ConfigContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
key: "createCopyJob",
iconSrc: AddIcon,
label: ContainerCopyMessages.createCopyJobButtonLabel,
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
onClick: Actions.openCreateCopyJobPanel.bind(null, explorer),
},
{
key: "refresh",
iconSrc: RefreshIcon,
label: ContainerCopyMessages.refreshButtonLabel,
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
onClick: () => monitorCopyJobsRef?.refreshJobList(),
},
];
if (configContext.platform === Platform.Portal) {
buttons.push({
key: "feedback",
iconSrc: FeedbackIcon,
label: ContainerCopyMessages.feedbackButtonLabel,
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
onClick: () => {
explorer.openContainerCopyFeedbackBlade();
},
});
}
return buttons;
}
function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps {
return {
iconSrc: config.iconSrc,
iconAlt: config.label,
onCommandClick: config.onClick,
commandButtonLabel: undefined as string | undefined,
ariaLabel: config.ariaLabel,
tooltipText: config.label,
hasPopup: false,
disabled: config.disabled ?? false,
};
}
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
}

View File

@@ -0,0 +1,177 @@
export default {
// Copy Job Command Bar
feedbackButtonLabel: "Feedback",
feedbackButtonAriaLabel: "Provide feedback on copy jobs",
refreshButtonLabel: "Refresh",
refreshButtonAriaLabel: "Refresh copy jobs",
createCopyJobButtonLabel: "Create Copy Job",
createCopyJobButtonAriaLabel: "Create a new container copy job",
// No Copy Jobs Found
noCopyJobsTitle: "No copy jobs to show",
createCopyJobButtonText: "Create a container copy job",
// Copy Job Details
copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details",
errorTitle: "Error Details",
selectedContainers: "Selected Containers",
// Create Copy Job Panel
createCopyJobPanelTitle: "Create copy job",
// Select Account Screen
selectAccountDescription: "Please select a source account from which to copy.",
subscriptionDropdownLabel: "Subscription",
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
migrationTypeCheckboxLabel: "Copy container in offline mode",
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:
"Please select a source container and a destination container to copy to.",
sourceContainerSubHeading: "Source container",
targetContainerSubHeading: "Destination container",
databaseDropdownLabel: "Database",
databaseDropdownPlaceholder: "Select a database",
containerDropdownLabel: "Container",
containerDropdownPlaceholder: "Select a container",
createNewContainerSubHeading: "Select the properties for your container.",
createContainerButtonLabel: "Create a new container",
createContainerHeading: "Create new container",
// Preview and Create Screen
jobNameLabel: "Job name",
sourceSubscriptionLabel: "Source subscription",
sourceAccountLabel: "Source account",
sourceDatabaseLabel: "Source database",
sourceContainerLabel: "Source container",
targetDatabaseLabel: "Destination database",
targetContainerLabel: "Destination container",
// Assign Permissions Screen
assignPermissions: {
crossAccountDescription:
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`,
crossAccountConfiguration: {
title: "Cross-account container copy",
description: (sourceAccount: string, destinationAccount: string) =>
`Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
},
onlineConfiguration: {
title: "Online container copy",
description: (accountName: string) =>
`Please follow the instructions below to enable online copy on your "${accountName}" account.`,
},
},
toggleBtn: {
onText: "On",
offText: "Off",
},
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
addManagedIdentity: {
title: "System-assigned managed identity enabled.",
description:
"A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.",
descriptionHrefText: "Learn more about Managed identities.",
descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
toggleLabel: "System assigned managed identity",
tooltip: {
content: "Learn more about",
hrefText: "Managed Identities.",
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
},
userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.",
userAssignedIdentityLabel: "You may also select a user assigned managed identity.",
createUserAssignedIdentityLink: "Create User Assigned Managed Identity",
enablementTitle: "Enable system assigned managed identity",
enablementDescription: (accountName: string) =>
accountName
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. `
: "",
},
defaultManagedIdentity: {
title: "System-assigned managed identity set as default.",
description: (accountName: string) =>
`Set the system-assigned managed identity as default for "${accountName}" by switching it on.`,
tooltip: {
content: "Learn more about",
hrefText: "Default Managed Identities.",
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
},
popoverTitle: "System assigned managed identity set as default",
popoverDescription: (accountName: string) =>
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
},
readPermissionAssigned: {
title: "Read permissions assigned to the default identity.",
description:
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
tooltip: {
content: "Learn more about",
hrefText: "Read permissions.",
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
},
popoverTitle: "Read permissions assigned to default identity.",
popoverDescription:
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ",
},
pointInTimeRestore: {
title: "Point In Time Restore enabled",
description: (accessName: string) =>
`To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`,
tooltip: {
content: "Learn more about",
hrefText: "Continuous Backup",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
},
buttonText: "Enable Point In Time Restore",
},
onlineCopyEnabled: {
title: "Online copy enabled",
description: (accountName: string) =>
`Enable online container copy by clicking the button below on your "${accountName}" account.`,
hrefText: "Learn more about online copy jobs",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
buttonText: "Enable Online Copy",
validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Validating All versions and deletes change feed mode (preview)...",
enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Enabling All versions and deletes change feed mode (preview)...",
enablingOnlineCopySpinnerLabel: (accountName: string) =>
`Enabling online copy on your "${accountName}" account ...`,
},
MonitorJobs: {
Columns: {
lastUpdatedTime: "Date & time",
name: "Job name",
status: "Status",
completionPercentage: "Completion %",
duration: "Duration",
error: "Error message",
mode: "Mode",
actions: "Actions",
},
Actions: {
pause: "Pause",
resume: "Resume",
cancel: "Cancel",
complete: "Complete",
viewDetails: "View Details",
},
Status: {
Pending: "Pending",
InProgress: "In Progress",
Running: "In Progress",
Partitioning: "In Progress",
Paused: "Paused",
Completed: "Completed",
Failed: "Failed",
Faulted: "Failed",
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
},
};

View File

@@ -0,0 +1,23 @@
import React, { useEffect } from "react";
import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar";
import "./containerCopyStyles.less";
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
import { ContainerCopyProps } from "./Types/CopyJobTypes";
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ explorer }) => {
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
useEffect(() => {
if (monitorCopyJobsRef.current) {
MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current);
}
}, [monitorCopyJobsRef.current]);
return (
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
<CopyJobCommandBar explorer={explorer} />
<MonitorCopyJobs ref={monitorCopyJobsRef} explorer={explorer} />
</div>
);
};
export default ContainerCopyPanel;

View File

@@ -0,0 +1,67 @@
import Explorer from "Explorer/Explorer";
import { Subscription } from "Contracts/DataModels";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
export const useCopyJobContext = (): CopyJobContextProviderType => {
const context = React.useContext(CopyJobContext);
if (!context) {
throw new Error("useCopyJobContext must be used within a CopyJobContextProvider");
}
return context;
};
interface CopyJobContextProviderProps {
children: React.ReactNode;
explorer: Explorer;
}
const getInitialCopyJobState = (): CopyJobContextState => {
return {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: {
subscriptionId: userContext.subscriptionId || "",
} as Subscription,
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: userContext.subscriptionId || "",
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
};
};
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
const [contextError, setContextError] = React.useState<string | null>(null);
const resetCopyJobState = () => {
setCopyJobState(getInitialCopyJobState());
};
const contextValue: CopyJobContextProviderType = {
contextError,
setContextError,
copyJobState,
setCopyJobState,
flow,
setFlow,
resetCopyJobState,
explorer: props.explorer,
};
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
};
export default CopyJobContextProvider;

View File

@@ -0,0 +1,171 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
export const buildResourceLink = (resource: DatabaseAccount): string => {
const resourceId = resource.id;
let parentOrigin = window.location.ancestorOrigins?.[0] ?? window.location.origin;
if (/\/\/localhost:/.test(parentOrigin)) {
parentOrigin = azurePortalMpacEndpoint;
} else if (/\/\/cosmos\.azure/.test(parentOrigin)) {
parentOrigin = parentOrigin.replace("cosmos.azure", "portal.azure");
}
parentOrigin = parentOrigin.replace(/\/$/, "");
return `${parentOrigin}/#resource${resourceId}`;
};
export const COSMOS_SQL_COMPONENT = "CosmosDBSql";
export const COPY_JOB_API_VERSION = "2025-05-01-preview";
export function buildDataTransferJobPath({
subscriptionId,
resourceGroup,
accountName,
jobName,
action,
}: {
subscriptionId: string;
resourceGroup: string;
accountName: string;
jobName?: string;
action?: string;
}) {
let path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`;
if (jobName) {
path += `/${jobName}`;
}
if (action) {
path += `/${action}`;
}
return path;
}
export function convertTime(timeStr: string): string | null {
const timeParts = timeStr.split(":").map(Number);
if (timeParts.length !== 3 || timeParts.some(isNaN)) {
return null;
}
const formatPart = (value: number, unit: string) => {
if (unit === "seconds") {
value = Math.round(value);
}
return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : "";
};
const [hours, minutes, seconds] = timeParts;
const formattedTimeParts = [
formatPart(hours, "hours"),
formatPart(minutes, "minutes"),
formatPart(seconds, "seconds"),
]
.filter(Boolean)
.join(", ");
return formattedTimeParts || "0 seconds";
}
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
const date = new Date(utcStr);
if (isNaN(date.getTime())) {
return null;
}
return {
formattedDateTime: new Intl.DateTimeFormat("en-US", {
dateStyle: "short",
timeStyle: "medium",
timeZone: "UTC",
}).format(date),
timestamp: date.getTime(),
};
}
export function convertToCamelCase(str: string): string {
const formattedStr = str
.split(/\s+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
return formattedStr;
}
export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType {
return {
...error,
message: error.message.split("\r\n\r\n")[0],
};
}
export function getAccountDetailsFromResourceId(accountId: string | undefined) {
if (!accountId) {
return null;
}
const pattern = new RegExp(
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)",
"i",
);
const matches = accountId.match(pattern);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
return { subscriptionId, resourceGroup, accountName };
}
export function getContainerIdentifiers(container: CopyJobContextState["source"] | CopyJobContextState["target"]) {
return {
accountId: container?.account?.id || "",
databaseId: container?.databaseId || "",
containerId: container?.containerId || "",
};
}
export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean {
const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId);
const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId);
return (
sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId &&
sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup &&
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
);
}
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
if (prevJobs.length !== newJobs.length) {
return false;
}
return prevJobs.every((prevJob: CopyJobType) => {
const newJob = newJobs.find((job) => job.Name === prevJob.Name);
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status;
});
}
const truncateLength = 5;
const truncateName = (name: string, length: number = truncateLength): string => {
return name.length <= length ? name : name.slice(0, length);
};
export function getDefaultJobName(
selectedDatabaseAndContainers: {
sourceDatabaseName?: string;
sourceContainerName?: string;
targetDatabaseName?: string;
targetContainerName?: string;
}[],
): string {
if (selectedDatabaseAndContainers.length === 1) {
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
selectedDatabaseAndContainers[0];
const timestamp = new Date().getTime().toString();
const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`;
const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`;
return `${sourcePart}_${targetPart}_${timestamp}`;
}
return "";
}

View File

@@ -0,0 +1,56 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import useManagedIdentity from "./hooks/useManagedIdentity";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.addManagedIdentity.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
</Link>
</Text>
);
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState } = useCopyJobContext();
const [systemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
return (
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text>
{ContainerCopyMessages.addManagedIdentity.description}&ensp;
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
</Link>{" "}
&nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</Text>
<Toggle
checked={systemAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
/>
<PopoverMessage
isLoading={loading}
visible={systemAssigned}
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
</PopoverMessage>
</Stack>
);
};
export default AddManagedIdentity;

View File

@@ -0,0 +1,91 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import { logError } from "../../../../../Common/Logger";
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const TooltipContent = (
<Text>
{ContainerCopyMessages.readPermissionAssigned.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
</Link>
</Text>
);
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
const [loading, setLoading] = React.useState(false);
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const handleAddReadPermission = async () => {
const { source, target } = copyJobState;
const selectedSourceAccount = source?.account;
try {
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
setLoading(true);
const assignedRole = await assignRole(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
target?.account?.identity?.principalId ?? "",
);
if (assignedRole) {
setCopyJobState((prevState) => ({
...prevState,
sourceReadAccessFromTarget: true,
}));
}
} catch (error) {
const errorMessage =
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
setContextError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text className="toggle-label">
{ContainerCopyMessages.readPermissionAssigned.description}&ensp;
<InfoTooltip content={TooltipContent} />
</Text>
<Toggle
checked={readPermissionAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
inlineLabel
styles={{
root: { marginTop: 8, marginBottom: 12 },
label: { display: "none" },
}}
/>
<PopoverMessage
isLoading={loading}
visible={readPermissionAssigned}
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddReadPermission}
>
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
</PopoverMessage>
</Stack>
);
};
export default AddReadPermissionToDefaultIdentity;

View File

@@ -0,0 +1,124 @@
import { Image, Stack, Text } from "@fluentui/react";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
import React, { useEffect } from "react";
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
import WarningIcon from "../../../../../../images/warning.svg";
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { isIntraAccountCopy } from "../../../CopyJobUtils";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
<AccordionItem key={id} value={id} disabled={disabled}>
<AccordionHeader className="accordionHeader">
<Text className="accordionHeaderText" variant="medium">
{title}
</Text>
<Image
className="statusIcon"
src={completed ? CheckmarkIcon : WarningIcon}
alt={completed ? "Checkmark icon" : "Warning icon"}
width={completed ? 20 : 24}
height={completed ? 20 : 24}
/>
</AccordionHeader>
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
<Component />
</AccordionPanel>
</AccordionItem>
);
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
const [openItems, setOpenItems] = React.useState<string[]>([]);
useEffect(() => {
const firstIncompleteSection = sections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [sections]);
return (
<Stack
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "#fafafa",
border: "1px solid #e1e1e1",
borderRadius: 8,
padding: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
}}
>
<Stack tokens={{ childrenGap: 5 }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
</Stack>
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{sections.map((section) => (
<PermissionSection key={section.id} {...section} />
))}
</Accordion>
</Stack>
);
};
const AssignPermissions = () => {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const { copyJobState } = useCopyJobContext();
const permissionGroups = usePermissionSections(copyJobState);
const totalSectionsCount = React.useMemo(
() => permissionGroups.reduce((total, group) => total + group.sections.length, 0),
[permissionGroups],
);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[copyJobState.migrationType],
);
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
useEffect(() => {
return () => {
setValidationCache(new Map<string, boolean>());
};
}, []);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
)
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
</Text>
{totalSectionsCount === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (
<Stack tokens={{ childrenGap: 25 }}>
{permissionGroups.map((group) => (
<PermissionGroup key={group.id} {...group} />
))}
</Stack>
)}
</Stack>
);
};
export default AssignPermissions;

View File

@@ -0,0 +1,57 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import useManagedIdentity from "./hooks/useManagedIdentity";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
</Link>
</Text>
);
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState } = useCopyJobContext();
const [defaultSystemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<div className="toggle-label">
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)} &nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</div>
<Toggle
checked={defaultSystemAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
inlineLabel
styles={{
root: { marginTop: 8, marginBottom: 12 },
label: { display: "none" },
}}
/>
<PopoverMessage
isLoading={loading}
visible={defaultSystemAssigned}
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)}
</PopoverMessage>
</Stack>
);
};
export default DefaultManagedIdentity;

View File

@@ -0,0 +1,169 @@
import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CapabilityNames } from "../../../../../Common/Constants";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
const prevCapabilities = prev?.properties?.capabilities ?? [];
const nextCapabilities = next?.properties?.capabilities ?? [];
return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities);
};
const OnlineCopyEnabled: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [loaderMessage, setLoaderMessage] = React.useState("");
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const selectedSourceAccount = source?.account;
const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? [];
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
const handleFetchAccount = async () => {
try {
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
if (account && validatorFn(selectedSourceAccount, account)) {
setCopyJobState((prevState) => ({
...prevState,
source: { ...prevState.source, account: account },
}));
setLoading(false);
}
} catch (error) {
const errorMessage =
error.message || "Error fetching source account after enabling online copy. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount");
setContextError(errorMessage);
clearAccountFetchInterval();
}
};
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = () => {
setLoading(true);
handleFetchAccount();
};
const handleOnlineCopyEnable = async () => {
setLoading(true);
setShowRefreshButton(false);
try {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
const sourAccountBeforeUpdate = await fetchDatabaseAccount(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
);
if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
}
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
},
});
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
10 * 60 * 1000,
);
} catch (error) {
const errorMessage = error.message || "Failed to enable online copy feature. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
setContextError(errorMessage);
setLoading(false);
}
};
React.useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={loaderMessage} />
<Stack.Item className="info-message">
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp;
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
</Link>
</Stack.Item>
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
iconProps={{ iconName: "Refresh" }}
onClick={handleRefresh}
disabled={loading}
/>
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={handleOnlineCopyEnable}
/>
)}
</Stack.Item>
</Stack>
);
};
export default OnlineCopyEnabled;

View File

@@ -0,0 +1,145 @@
import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React, { useEffect, useRef, useState } from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
import InfoTooltip from "../Components/InfoTooltip";
const tooltipContent = (
<Text>
{ContainerCopyMessages.pointInTimeRestore.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
</Link>
</Text>
);
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? "";
const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? "";
return prevBackupPolicy !== nextBackupPolicy;
};
const PointInTimeRestore: React.FC = () => {
const [loading, setLoading] = useState(false);
const [showRefreshButton, setShowRefreshButton] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const sourceAccountLink = buildResourceLink(source?.account);
const featureUrl = `${sourceAccountLink}/backupRestore`;
const selectedSourceAccount = source?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const handleFetchAccount = async () => {
try {
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
if (account && validatorFn(selectedSourceAccount, account)) {
setCopyJobState((prevState) => ({
...prevState,
source: { ...prevState.source, account: account },
}));
setLoading(false);
}
} catch (error) {
const errorMessage =
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
clearAccountFetchInterval();
}
};
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = async () => {
setLoading(true);
await handleFetchAccount();
setLoading(false);
};
const openWindowAndMonitor = () => {
setLoading(true);
setShowRefreshButton(false);
window.open(featureUrl, "_blank");
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
10 * 60 * 1000,
);
};
return (
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="toggle-label">
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
{tooltipContent && (
<>
{" "}
<InfoTooltip content={tooltipContent} />
</>
)}
</Stack.Item>
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
iconProps={{ iconName: "Refresh" }}
onClick={handleRefresh}
/>
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={openWindowAndMonitor}
/>
)}
</Stack.Item>
</Stack>
);
};
export default PointInTimeRestore;

View File

@@ -0,0 +1,54 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { useCallback, useState } from "react";
import { logError } from "../../../../../../Common/Logger";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
interface UseManagedIdentityUpdaterParams {
updateIdentityFn: (
subscriptionId: string,
resourceGroup?: string,
accountName?: string,
) => Promise<DatabaseAccount | undefined>;
}
interface UseManagedIdentityUpdaterReturn {
loading: boolean;
handleAddSystemIdentity: () => Promise<void>;
}
const useManagedIdentity = (
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
): UseManagedIdentityUpdaterReturn => {
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [loading, setLoading] = useState<boolean>(false);
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
try {
setLoading(true);
const selectedTargetAccount = copyJobState?.target?.account;
const {
subscriptionId: targetSubscriptionId,
resourceGroup: targetResourceGroup,
accountName: targetAccountName,
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName);
if (updatedAccount) {
setCopyJobState((prevState) => ({
...prevState,
target: { ...prevState.target, account: updatedAccount },
}));
}
} catch (error) {
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
setLoading(false);
}
}, [copyJobState?.target?.account?.id, updateIdentityFn, setCopyJobState]);
return { loading, handleAddSystemIdentity };
};
export default useManagedIdentity;

View File

@@ -0,0 +1,262 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CapabilityNames } from "../../../../../../Common/Constants";
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { getAccountDetailsFromResourceId, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
DefaultIdentityType,
IdentityType,
} from "../../../../Enums/CopyJobEnums";
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
import AddManagedIdentity from "../AddManagedIdentity";
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
import DefaultManagedIdentity from "../DefaultManagedIdentity";
import OnlineCopyEnabled from "../OnlineCopyEnabled";
import PointInTimeRestore from "../PointInTimeRestore";
export interface PermissionSectionConfig {
id: string;
title: string;
Component: React.ComponentType;
disabled: boolean;
completed?: boolean;
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
}
export interface PermissionGroupConfig {
id: string;
title: string;
description: string;
sections: PermissionSectionConfig[];
}
export const SECTION_IDS = {
addManagedIdentity: "addManagedIdentity",
defaultManagedIdentity: "defaultManagedIdentity",
readPermissionAssigned: "readPermissionAssigned",
pointInTimeRestore: "pointInTimeRestore",
onlineCopyEnabled: "onlineCopyEnabled",
} as const;
const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
{
id: SECTION_IDS.addManagedIdentity,
title: ContainerCopyMessages.addManagedIdentity.title,
Component: AddManagedIdentity,
disabled: true,
validate: (state: CopyJobContextState) => {
const targetAccountIdentityType = (state?.target?.account?.identity?.type ?? "").toLowerCase();
return (
targetAccountIdentityType === IdentityType.SystemAssigned ||
targetAccountIdentityType === IdentityType.UserAssigned
);
},
},
{
id: SECTION_IDS.defaultManagedIdentity,
title: ContainerCopyMessages.defaultManagedIdentity.title,
Component: DefaultManagedIdentity,
disabled: true,
validate: (state: CopyJobContextState) => {
const targetAccountDefaultIdentity = (state?.target?.account?.properties?.defaultIdentity ?? "").toLowerCase();
return targetAccountDefaultIdentity === DefaultIdentityType.SystemAssignedIdentity;
},
},
{
id: SECTION_IDS.readPermissionAssigned,
title: ContainerCopyMessages.readPermissionAssigned.title,
Component: AddReadPermissionToDefaultIdentity,
disabled: true,
validate: async (state: CopyJobContextState) => {
const principalId = state?.target?.account?.identity?.principalId;
const selectedSourceAccount = state?.source?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
const rolesAssigned = await fetchRoleAssignments(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
principalId,
);
const roleDefinitions = await fetchRoleDefinitions(rolesAssigned ?? []);
return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []);
},
},
];
const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
{
id: SECTION_IDS.pointInTimeRestore,
title: ContainerCopyMessages.pointInTimeRestore.title,
Component: PointInTimeRestore,
disabled: true,
validate: (state: CopyJobContextState) => {
const sourceAccountBackupPolicy = state?.source?.account?.properties?.backupPolicy?.type ?? "";
return sourceAccountBackupPolicy === BackupPolicyType.Continuous;
},
},
{
id: SECTION_IDS.onlineCopyEnabled,
title: ContainerCopyMessages.onlineCopyEnabled.title,
Component: OnlineCopyEnabled,
disabled: true,
validate: (state: CopyJobContextState) => {
const accountCapabilities = state?.source?.account?.properties?.capabilities ?? [];
const onlineCopyCapability = accountCapabilities.find(
(capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature,
);
return !!onlineCopyCapability;
},
},
];
/**
* Checks if the user has the Reader role based on role definitions.
*/
export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean {
return roleDefinitions?.some(
(role) =>
role.name === "00000000-0000-0000-0000-000000000001" ||
role.permissions.some(
(permission) =>
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") &&
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read"),
),
);
}
/**
* Validates sections within a group sequentially.
*/
const validateSectionsInGroup = async (
sections: PermissionSectionConfig[],
state: CopyJobContextState,
validationCache: Map<string, boolean>,
): Promise<PermissionSectionConfig[]> => {
const result: PermissionSectionConfig[] = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (validationCache.has(section.id) && validationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
validationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
// Mark remaining sections in this group as incomplete
for (let j = i + 1; j < sections.length; j++) {
result.push({ ...sections[j], completed: false });
}
break;
}
} else {
validationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return result;
};
/**
* Returns the permission groups configuration for the Assign Permissions screen.
* Groups validate independently but sections within each group validate sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => {
const sourceAccount = getContainerIdentifiers(state.source);
const targetAccount = getContainerIdentifiers(state.target);
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
const [permissionGroups, setPermissionGroups] = useState<PermissionGroupConfig[] | null>(null);
const isValidatingRef = useRef(false);
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
const crossAccountSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
const sourceAccountName = state.source?.account?.name || "";
const targetAccountName = state.target?.account?.name || "";
if (crossAccountSections.length > 0) {
groups.push({
id: "crossAccountConfigs",
title: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.title,
description: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.description(
sourceAccountName,
targetAccountName,
),
sections: crossAccountSections,
});
}
if (state.migrationType === CopyJobMigrationType.Online) {
groups.push({
id: "onlineConfigs",
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description(sourceAccountName),
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
});
}
return groups;
}, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]);
const memoizedValidationCache = useMemo(() => {
if (state.migrationType === CopyJobMigrationType.Offline) {
validationCache.delete(SECTION_IDS.pointInTimeRestore);
validationCache.delete(SECTION_IDS.onlineCopyEnabled);
}
return validationCache;
}, [state.migrationType]);
useEffect(() => {
const validateGroups = async () => {
if (isValidatingRef.current) {
return;
}
isValidatingRef.current = true;
const newValidationCache = new Map(memoizedValidationCache);
// Validate all groups independently (in parallel)
const validatedGroups = await Promise.all(
groupsToValidate.map(async (group) => {
const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache);
return {
...group,
sections: validatedSections,
};
}),
);
setValidationCache(newValidationCache);
setPermissionGroups(validatedGroups);
isValidatingRef.current = false;
};
validateGroups();
return () => {
isValidatingRef.current = false;
};
}, [state, groupsToValidate]);
return permissionGroups ?? [];
};
export default usePermissionSections;

View File

@@ -0,0 +1,11 @@
import { useCallback, useState } from "react";
const useToggle = (initialState = false) => {
const [state, setState] = useState<boolean>(initialState);
const onToggle = useCallback((_, checked?: boolean) => {
setState(checked);
}, []);
return [state, onToggle] as const;
};
export default useToggle;

View File

@@ -0,0 +1,25 @@
import { Stack } from "@fluentui/react";
import React from "react";
interface FieldRowProps {
label?: string;
children: React.ReactNode;
labelClassName?: string;
}
const FieldRow: React.FC<FieldRowProps> = ({ label = "", children, labelClassName = "" }) => {
return (
<Stack horizontal horizontalAlign="space-between" className="flex-row">
{label && (
<Stack.Item align="center" className="flex-fixed-width">
<label className={`field-label ${labelClassName}`}>{label}: </label>
</Stack.Item>
)}
<Stack.Item align="center" className="flex-grow-col">
{children}
</Stack.Item>
</Stack>
);
};
export default FieldRow;

View File

@@ -0,0 +1,17 @@
import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react";
import React from "react";
import InfoIcon from "../../../../../../images/Info.svg";
const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => {
if (!content) {
return null;
}
const hostStyles: Partial<ITooltipHostStyles> = { root: { display: "inline-block" } };
return (
<TooltipHost content={content} calloutProps={{ gapSpace: 0 }} styles={hostStyles}>
<Image src={InfoIcon} alt="Information" width={14} height={14} />
</TooltipHost>
);
};
export default React.memo(InfoTooltip);

View File

@@ -0,0 +1,28 @@
import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react";
import React from "react";
type NavigationControlsProps = {
primaryBtnText: string;
onPrimary: () => void;
onPrevious: () => void;
onCancel: () => void;
isPrimaryDisabled: boolean;
isPreviousDisabled: boolean;
};
const NavigationControls: React.FC<NavigationControlsProps> = ({
primaryBtnText,
onPrimary,
onPrevious,
onCancel,
isPrimaryDisabled,
isPreviousDisabled,
}) => (
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
<DefaultButton text="Cancel" onClick={onCancel} />
</Stack>
);
export default React.memo(NavigationControls);

View File

@@ -0,0 +1,65 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
import React from "react";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
interface PopoverContainerProps {
isLoading?: boolean;
title?: string;
children?: React.ReactNode;
onPrimary: () => void;
onCancel: () => void;
}
const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
({ isLoading = false, title, children, onPrimary, onCancel }) => {
return (
<Stack
className={`popover-container foreground ${isLoading ? "loading" : ""}`}
tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack>
</Stack>
);
},
);
interface PopoverMessageProps {
isLoading?: boolean;
visible: boolean;
title: string;
onCancel: () => void;
onPrimary: () => void;
children: React.ReactNode;
}
const PopoverMessage: React.FC<PopoverMessageProps> = ({
isLoading = false,
visible,
title,
onCancel,
onPrimary,
children,
}) => {
if (!visible) {
return null;
}
return (
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary} isLoading={isLoading}>
{children}
</PopoverContainer>
);
};
export default PopoverMessage;

View File

@@ -0,0 +1,53 @@
import { Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { useSidePanel } from "hooks/useSidePanel";
import { produce } from "immer";
import React, { useCallback, useEffect } from "react";
import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
type AddCollectionPanelWrapperProps = {
explorer?: Explorer;
goBack?: () => void;
};
const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapperProps> = ({ explorer, goBack }) => {
const { setCopyJobState } = useCopyJobContext();
useEffect(() => {
const sidePanelStore = useSidePanel.getState();
if (sidePanelStore.headerText !== ContainerCopyMessages.createContainerHeading) {
sidePanelStore.setHeaderText(ContainerCopyMessages.createContainerHeading);
}
return () => {
sidePanelStore.setHeaderText(ContainerCopyMessages.createCopyJobPanelTitle);
};
}, []);
const handleAddCollectionSuccess = useCallback(
(collectionData: { databaseId: string; collectionId: string }) => {
setCopyJobState(
produce((state) => {
state.target.databaseId = collectionData.databaseId;
state.target.containerId = collectionData.collectionId;
}),
);
goBack?.();
},
[goBack],
);
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
</Stack.Item>
</Stack>
);
};
export default AddCollectionPanelWrapper;

View File

@@ -0,0 +1,52 @@
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
import NavigationControls from "./Components/NavigationControls";
const CreateCopyJobScreens: React.FC = () => {
const {
currentScreen,
isPrimaryDisabled,
isPreviousDisabled,
handlePrimary,
handlePrevious,
handleCancel,
primaryBtnText,
showAddCollectionPanel,
} = useCopyJobNavigation();
const { contextError, setContextError } = useCopyJobContext();
return (
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
<Stack.Item className="createCopyJobScreensContent">
{contextError && (
<MessageBar
className="createCopyJobErrorMessageBar"
messageBarType={MessageBarType.blocked}
isMultiline={false}
onDismiss={() => setContextError(null)}
dismissButtonAriaLabel="Close"
truncated={true}
overflowButtonAriaLabel="See more"
>
{contextError}
</MessageBar>
)}
{React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })}
</Stack.Item>
<Stack.Item className="createCopyJobScreensFooter">
<NavigationControls
primaryBtnText={primaryBtnText}
onPrimary={handlePrimary}
onPrevious={handlePrevious}
onCancel={handleCancel}
isPrimaryDisabled={isPrimaryDisabled}
isPreviousDisabled={isPreviousDisabled}
/>
</Stack.Item>
</Stack>
);
};
export default CreateCopyJobScreens;

View File

@@ -0,0 +1,14 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import CopyJobContextProvider from "../../Context/CopyJobContext";
import CreateCopyJobScreens from "./CreateCopyJobScreens";
const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => {
return (
<CopyJobContextProvider explorer={explorer}>
<CreateCopyJobScreens />
</CopyJobContextProvider>
);
};
export default CreateCopyJobScreensProvider;

View File

@@ -0,0 +1,58 @@
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
import React, { useEffect } from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getDefaultJobName } from "../../../CopyJobUtils";
import FieldRow from "../Components/FieldRow";
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
const PreviewCopyJob: React.FC = () => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const selectedDatabaseAndContainers = [
{
sourceDatabaseName: copyJobState.source?.databaseId,
sourceContainerName: copyJobState.source?.containerId,
targetDatabaseName: copyJobState.target?.databaseId,
targetContainerName: copyJobState.target?.containerId,
},
];
useEffect(() => {
onJobNameChange(undefined, getDefaultJobName(selectedDatabaseAndContainers));
}, []);
const jobName = copyJobState.jobName;
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {
setCopyJobState((prevState) => ({
...prevState,
jobName: newValue || "",
}));
};
return (
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer">
<FieldRow label={ContainerCopyMessages.jobNameLabel}>
<TextField value={jobName} onChange={onJobNameChange} />
</FieldRow>
<Stack>
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text>{copyJobState.source?.subscription?.displayName}</Text>
</Stack>
<Stack>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{copyJobState.source?.account?.name}</Text>
</Stack>
<Stack>
<DetailsList
items={selectedDatabaseAndContainers}
layoutMode={DetailsListLayoutMode.justified}
checkboxVisibility={2}
columns={getPreviewCopyJobDetailsListColumns()}
/>
</Stack>
</Stack>
);
};
export default PreviewCopyJob;

View File

@@ -0,0 +1,43 @@
import { IColumn } from "@fluentui/react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
const commonProps = {
minWidth: 130,
maxWidth: 140,
styles: {
root: {
whiteSpace: "normal",
lineHeight: "1.2",
wordBreak: "break-word",
},
},
};
export const getPreviewCopyJobDetailsListColumns = (): IColumn[] => {
return [
{
key: "sourcedbname",
name: ContainerCopyMessages.sourceDatabaseLabel,
fieldName: "sourceDatabaseName",
...commonProps,
},
{
key: "sourcecolname",
name: ContainerCopyMessages.sourceContainerLabel,
fieldName: "sourceContainerName",
...commonProps,
},
{
key: "targetdbname",
name: ContainerCopyMessages.targetDatabaseLabel,
fieldName: "targetDatabaseName",
...commonProps,
},
{
key: "targetcolname",
name: ContainerCopyMessages.targetContainerLabel,
fieldName: "targetContainerName",
...commonProps,
},
];
};

View File

@@ -0,0 +1,31 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Dropdown } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
import FieldRow from "../../Components/FieldRow";
interface AccountDropdownProps {
options: DropdownOptionType[];
selectedKey?: string;
disabled: boolean;
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
}
export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
({ options, selectedKey, disabled, onChange }) => (
<FieldRow label={ContainerCopyMessages.sourceAccountDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.sourceAccountDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.sourceAccountDropdownLabel}
options={options}
disabled={disabled}
required
selectedKey={selectedKey}
onChange={onChange}
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -0,0 +1,16 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Checkbox, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
interface MigrationTypeCheckboxProps {
checked: boolean;
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
}
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow">
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
</Stack>
));

View File

@@ -0,0 +1,29 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Dropdown } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
import FieldRow from "../../Components/FieldRow";
interface SubscriptionDropdownProps {
options: DropdownOptionType[];
selectedKey?: string;
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
}
export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.memo(
({ options, selectedKey, onChange }) => (
<FieldRow label={ContainerCopyMessages.subscriptionDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.subscriptionDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.subscriptionDropdownLabel}
options={options}
required
selectedKey={selectedKey}
onChange={onChange}
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -0,0 +1,52 @@
/* eslint-disable react/display-name */
import { Stack } from "@fluentui/react";
import React from "react";
import { apiType } from "UserContext";
import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels";
import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts";
import { useSubscriptions } from "../../../../../hooks/useSubscriptions";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { AccountDropdown } from "./Components/AccountDropdown";
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils";
const SelectAccount = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const selectedSourceAccountId = copyJobState?.source?.account?.id;
const subscriptions: Subscription[] = useSubscriptions();
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL");
const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
return (
<Stack className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
<span>{ContainerCopyMessages.selectAccountDescription}</span>
<SubscriptionDropdown
options={subscriptionOptions}
selectedKey={selectedSubscriptionId}
onChange={(_ev, option) => handleSelectSourceAccount("subscription", option?.data)}
/>
<AccountDropdown
options={accountOptions}
selectedKey={selectedSourceAccountId}
disabled={!selectedSubscriptionId}
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
/>
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
</Stack>
);
});
export default SelectAccount;

View File

@@ -0,0 +1,73 @@
import React from "react";
import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
export function useDropdownOptions(
subscriptions: Subscription[],
accounts: DatabaseAccount[],
): {
subscriptionOptions: DropdownOptionType[];
accountOptions: DropdownOptionType[];
} {
const subscriptionOptions =
subscriptions?.map((sub) => ({
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
})) || [];
const accountOptions =
accounts?.map((account) => ({
key: account.id,
text: account.name,
data: account,
})) || [];
return { subscriptionOptions, accountOptions };
}
type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const handleSelectSourceAccount = (
type: "subscription" | "account",
data: (Subscription & DatabaseAccount) | undefined,
) => {
setCopyJobState((prevState: CopyJobContextState) => {
if (type === "subscription") {
return {
...prevState,
source: {
...prevState.source,
subscription: data || null,
account: null,
},
};
}
if (type === "account") {
return {
...prevState,
source: {
...prevState.source,
account: data || null,
},
};
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
};
const handleMigrationTypeChange = React.useCallback((_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState: CopyJobContextState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
}, []);
return { handleSelectSourceAccount, handleMigrationTypeChange };
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
export function dropDownChangeHandler(setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>) {
return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") =>
(_evnt: React.FormEvent, option: DropdownOptionType) => {
const value = option.key;
setCopyJobState((prevState) => {
switch (type) {
case "sourceDatabase":
return {
...prevState,
source: { ...prevState.source, databaseId: value, containerId: undefined },
};
case "sourceContainer":
return {
...prevState,
source: { ...prevState.source, containerId: value },
};
case "targetDatabase":
return {
...prevState,
target: { ...prevState.target, databaseId: value, containerId: undefined },
};
case "targetContainer":
return {
...prevState,
target: { ...prevState.target, containerId: value },
};
default:
return prevState;
}
});
};
}

View File

@@ -0,0 +1,79 @@
import { Stack } from "@fluentui/react";
import { DatabaseModel } from "Contracts/DataModels";
import React from "react";
import { useDatabases } from "../../../../../hooks/useDatabases";
import { useDataContainers } from "../../../../../hooks/useDataContainers";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
import { useSourceAndTargetData } from "./memoizedData";
type SelectSourceAndTargetContainers = {
showAddCollectionPanel?: () => void;
};
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
useSourceAndTargetData(copyJobState);
if (!source) {
return null;
}
const sourceDatabases = useDatabases(...sourceDbParams);
const sourceContainers = useDataContainers(...sourceContainerParams);
const targetDatabases = useDatabases(...targetDbParams);
const targetContainers = useDataContainers(...targetContainerParams);
const sourceDatabaseOptions = React.useMemo(
() => sourceDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
[sourceDatabases],
);
const sourceContainerOptions = React.useMemo(
() => sourceContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
[sourceContainers],
);
const targetDatabaseOptions = React.useMemo(
() => targetDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
[targetDatabases],
);
const targetContainerOptions = React.useMemo(
() => targetContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
[targetContainers],
);
const onDropdownChange = dropDownChangeHandler(setCopyJobState);
return (
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<DatabaseContainerSection
heading={ContainerCopyMessages.sourceContainerSubHeading}
databaseOptions={sourceDatabaseOptions}
selectedDatabase={source?.databaseId}
databaseDisabled={false}
databaseOnChange={onDropdownChange("sourceDatabase")}
containerOptions={sourceContainerOptions}
selectedContainer={source?.containerId}
containerDisabled={!source?.databaseId}
containerOnChange={onDropdownChange("sourceContainer")}
/>
<DatabaseContainerSection
heading={ContainerCopyMessages.targetContainerSubHeading}
databaseOptions={targetDatabaseOptions}
selectedDatabase={target?.databaseId}
databaseDisabled={false}
databaseOnChange={onDropdownChange("targetDatabase")}
containerOptions={targetContainerOptions}
selectedContainer={target?.containerId}
containerDisabled={!target?.databaseId}
containerOnChange={onDropdownChange("targetContainer")}
handleOnDemandCreateContainer={showAddCollectionPanel}
/>
</Stack>
);
};
export default SelectSourceAndTargetContainers;

View File

@@ -0,0 +1,51 @@
import { ActionButton, Dropdown, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes";
import FieldRow from "../../Components/FieldRow";
export const DatabaseContainerSection = ({
heading,
databaseOptions,
selectedDatabase,
databaseDisabled,
databaseOnChange,
containerOptions,
selectedContainer,
containerDisabled,
containerOnChange,
handleOnDemandCreateContainer,
}: DatabaseContainerSectionProps) => (
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
<label className="subHeading">{heading}</label>
<FieldRow label={ContainerCopyMessages.databaseDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.databaseDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.databaseDropdownLabel}
options={databaseOptions}
required
disabled={!!databaseDisabled}
selectedKey={selectedDatabase}
onChange={databaseOnChange}
/>
</FieldRow>
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
<Stack>
<Dropdown
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
options={containerOptions}
required
disabled={!!containerDisabled}
selectedKey={selectedContainer}
onChange={containerOnChange}
/>
{handleOnDemandCreateContainer && (
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
{ContainerCopyMessages.createContainerButtonLabel}
</ActionButton>
)}
</Stack>
</FieldRow>
</Stack>
);

View File

@@ -0,0 +1,37 @@
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
export function useSourceAndTargetData(copyJobState: CopyJobContextState) {
const { source, target } = copyJobState ?? {};
const selectedSourceAccount = source?.account;
const selectedTargetAccount = target?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
const {
subscriptionId: targetSubscriptionId,
resourceGroup: targetResourceGroup,
accountName: targetAccountName,
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams;
const sourceContainerParams = [
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
source?.databaseId,
"SQL",
] as DataContainerParams;
const targetDbParams = [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams;
const targetContainerParams = [
targetSubscriptionId,
targetResourceGroup,
targetAccountName,
target?.databaseId,
"SQL",
] as DataContainerParams;
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
}

View File

@@ -0,0 +1,164 @@
import { useCallback, useMemo, useReducer, useState } from "react";
import { useSidePanel } from "../../../../hooks/useSidePanel";
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils";
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
type NavigationState = {
screenHistory: string[];
};
type Action = { type: "NEXT"; nextScreen: string } | { type: "PREVIOUS" } | { type: "RESET" };
function navigationReducer(state: NavigationState, action: Action): NavigationState {
switch (action.type) {
case "NEXT":
return {
screenHistory: [...state.screenHistory, action.nextScreen],
};
case "PREVIOUS":
return {
screenHistory: state.screenHistory.length > 1 ? state.screenHistory.slice(0, -1) : state.screenHistory,
};
case "RESET":
return {
screenHistory: [SCREEN_KEYS.SelectAccount],
};
default:
return state;
}
}
export function useCopyJobNavigation() {
const [isLoading, setIsLoading] = useState(false);
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
const { validationCache: cache } = useCopyJobPrerequisitesCache();
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
const handlePrevious = useCallback(() => {
dispatch({ type: "PREVIOUS" });
}, [dispatch]);
const screens = useCreateCopyJobScreensList(handlePrevious);
const currentScreenKey = state.screenHistory[state.screenHistory.length - 1];
const currentScreen = screens.find((screen) => screen.key === currentScreenKey);
const isPrimaryDisabled = useMemo(() => {
if (isLoading) {
return true;
}
const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState;
return !currentScreen?.validations.every((v) => v.validate(context));
}, [currentScreen.key, copyJobState, cache, isLoading]);
const primaryBtnText = useMemo(() => {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
return "Create";
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
return "Copy";
}
return "Next";
}, [currentScreenKey]);
const isPreviousDisabled = state.screenHistory.length <= 1;
const handleCancel = useCallback(() => {
dispatch({ type: "RESET" });
resetCopyJobState();
useSidePanel.getState().closeSidePanel();
}, []);
const areContainersIdentical = () => {
const { source, target } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
const targetIds = getContainerIdentifiers(target);
return (
isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) &&
sourceIds.databaseId === targetIds.databaseId &&
sourceIds.containerId === targetIds.containerId
);
};
const shouldNotShowPermissionScreen = () => {
const { source, target, migrationType } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
const targetIds = getContainerIdentifiers(target);
return (
migrationType === CopyJobMigrationType.Offline && isIntraAccountCopy(sourceIds.accountId, targetIds.accountId)
);
};
const handleCopyJobSubmission = async () => {
try {
setIsLoading(true);
await submitCreateCopyJob(copyJobState, handleCancel);
} catch (error: unknown) {
const errorMessage =
error instanceof Error
? error.message || "Failed to create copy job. Please try again later."
: "Failed to create copy job. Please try again later.";
setContextError(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleAddCollectionPanelSubmit = () => {
const form = document.getElementById("panelContainer") as HTMLFormElement;
if (form) {
const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
});
form.dispatchEvent(submitEvent);
}
};
const showAddCollectionPanel = useCallback(() => {
dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.CreateCollection });
}, [dispatch]);
const handlePrimary = useCallback(() => {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
handleAddCollectionPanelSubmit();
return;
}
if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) {
setContextError(
"Source and destination containers cannot be the same. Please select different containers to proceed.",
);
return;
}
setContextError(null);
const transitions = {
[SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen()
? SCREEN_KEYS.SelectSourceAndTargetContainers
: SCREEN_KEYS.AssignPermissions,
[SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers,
[SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob,
};
const nextScreen = transitions[currentScreenKey];
if (nextScreen) {
dispatch({ type: "NEXT", nextScreen });
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
handleCopyJobSubmission();
}
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
return {
currentScreen,
isPrimaryDisabled,
isPreviousDisabled,
handlePrimary,
handlePrevious,
handleCancel,
showAddCollectionPanel,
primaryBtnText,
};
}

View File

@@ -0,0 +1,11 @@
import create from "zustand";
interface CopyJobPrerequisitesCacheState {
validationCache: Map<string, boolean>;
setValidationCache: (cache: Map<string, boolean>) => void;
}
export const useCopyJobPrerequisitesCache = create<CopyJobPrerequisitesCacheState>((set) => ({
validationCache: new Map<string, boolean>(),
setValidationCache: (cache) => set({ validationCache: cache }),
}));

View File

@@ -0,0 +1,97 @@
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { CopyJobContextState } from "../../Types/CopyJobTypes";
import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions";
import AddCollectionPanelWrapper from "../Screens/CreateContainer/AddCollectionPanelWrapper";
import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob";
import SelectAccount from "../Screens/SelectAccount/SelectAccount";
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
const SCREEN_KEYS = {
CreateCollection: "CreateCollection",
SelectAccount: "SelectAccount",
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
PreviewCopyJob: "PreviewCopyJob",
AssignPermissions: "AssignPermissions",
};
type Validation = {
validate: (state: CopyJobContextState | Map<string, boolean>) => boolean;
message: string;
};
type Screen = {
key: string;
component: React.ReactElement;
validations: Validation[];
};
function useCreateCopyJobScreensList(goBack: () => void): Screen[] {
const { explorer } = useCopyJobContext();
return React.useMemo<Screen[]>(
() => [
{
key: SCREEN_KEYS.SelectAccount,
component: <SelectAccount />,
validations: [
{
validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account,
message: "Please select a subscription and account to proceed",
},
],
},
{
key: SCREEN_KEYS.SelectSourceAndTargetContainers,
component: <SelectSourceAndTargetContainers />,
validations: [
{
validate: (state: CopyJobContextState) =>
!!state?.source?.databaseId &&
!!state?.source?.containerId &&
!!state?.target?.databaseId &&
!!state?.target?.containerId,
message: "Please select source and target containers to proceed",
},
],
},
{
key: SCREEN_KEYS.CreateCollection,
component: <AddCollectionPanelWrapper explorer={explorer} goBack={goBack} />,
validations: [],
},
{
key: SCREEN_KEYS.PreviewCopyJob,
component: <PreviewCopyJob />,
validations: [
{
validate: (state: CopyJobContextState) =>
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-._]+$/.test(state?.jobName)),
message: "Please enter a job name to proceed",
},
],
},
{
key: SCREEN_KEYS.AssignPermissions,
component: <AssignPermissions />,
validations: [
{
validate: (cache: Map<string, boolean>) => {
const cacheValuesIterator = Array.from(cache.values());
if (cacheValuesIterator.length === 0) {
return false;
}
const allValid = cacheValuesIterator.every((isValid: boolean) => isValid);
return allValid;
},
message: "Please ensure all previous steps are valid to proceed",
},
],
},
],
[explorer],
);
}
export { SCREEN_KEYS, useCreateCopyJobScreensList };

View File

@@ -0,0 +1,39 @@
export enum CopyJobMigrationType {
Offline = "offline",
Online = "online",
}
export enum IdentityType {
SystemAssigned = "systemassigned",
UserAssigned = "userassigned",
None = "none",
}
export enum DefaultIdentityType {
SystemAssignedIdentity = "systemassignedidentity",
}
export enum BackupPolicyType {
Continuous = "Continuous",
Periodic = "Periodic",
}
export enum CopyJobStatusType {
Pending = "Pending",
InProgress = "InProgress",
Running = "Running",
Partitioning = "Partitioning",
Paused = "Paused",
Skipped = "Skipped",
Completed = "Completed",
Cancelled = "Cancelled",
Failed = "Failed",
Faulted = "Faulted",
}
export enum CopyJobActions {
pause = "pause",
resume = "resume",
cancel = "cancel",
complete = "complete",
}

View File

@@ -0,0 +1,96 @@
import { IconButton, IContextualMenuProps } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
interface CopyJobActionMenuProps {
job: CopyJobType;
handleClick: HandleJobActionClickType;
}
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
if (
[
CopyJobStatusType.Completed,
CopyJobStatusType.Cancelled,
CopyJobStatusType.Failed,
CopyJobStatusType.Faulted,
].includes(job.Status)
) {
return null;
}
const getMenuItems = (): IContextualMenuProps["items"] => {
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
const updatingAction = updatingJobAction?.action;
const baseItems = [
{
key: CopyJobActions.pause,
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
iconProps: { iconName: "Pause" },
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
},
{
key: CopyJobActions.cancel,
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
iconProps: { iconName: "Cancel" },
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
},
{
key: CopyJobActions.resume,
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
iconProps: { iconName: "Play" },
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
},
];
if (CopyJobStatusType.Paused === job.Status) {
return baseItems.filter((item) => item.key !== CopyJobActions.pause);
}
if (CopyJobStatusType.Pending === job.Status) {
return baseItems.filter((item) => item.key !== CopyJobActions.resume);
}
if (
[CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status)
) {
const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume);
if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) {
filteredItems.push({
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
iconProps: { iconName: "CheckMark" },
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
});
}
return filteredItems;
}
if ([CopyJobStatusType.Skipped].includes(job.Status)) {
return baseItems.filter((item) => item.key === CopyJobActions.resume);
}
return baseItems;
};
return (
<IconButton
role="button"
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
menuProps={{ items: getMenuItems() }}
menuIconProps={{ iconName: "" }}
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
/>
);
};
export default CopyJobActionMenu;

View File

@@ -0,0 +1,80 @@
import { IColumn } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import CopyJobActionMenu from "./CopyJobActionMenu";
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
export const getColumns = (
handleSort: (columnKey: string) => void,
handleActionClick: HandleJobActionClickType,
sortedColumnKey: string | undefined,
isSortedDescending: boolean,
): IColumn[] => [
{
key: "LastUpdatedTime",
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
fieldName: "LastUpdatedTime",
minWidth: 140,
maxWidth: 300,
isResizable: true,
isSorted: sortedColumnKey === "timestamp",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("timestamp"),
},
{
key: "Name",
name: ContainerCopyMessages.MonitorJobs.Columns.name,
fieldName: "Name",
minWidth: 140,
maxWidth: 300,
isResizable: true,
isSorted: sortedColumnKey === "Name",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("Name"),
onRender: (job: CopyJobType) => <span className="jobNameLink">{job.Name}</span>,
},
{
key: "Mode",
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
fieldName: "Mode",
minWidth: 90,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "Mode",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("Mode"),
},
{
key: "CompletionPercentage",
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
fieldName: "CompletionPercentage",
minWidth: 110,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "CompletionPercentage",
isSortedDescending: isSortedDescending,
onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`,
onColumnClick: () => handleSort("CompletionPercentage"),
},
{
key: "CopyJobStatus",
name: ContainerCopyMessages.MonitorJobs.Columns.status,
fieldName: "Status",
minWidth: 130,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "Status",
isSortedDescending: isSortedDescending,
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
onColumnClick: () => handleSort("Status"),
},
{
key: "Actions",
name: "",
minWidth: 80,
maxWidth: 200,
isResizable: true,
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
},
];

View File

@@ -0,0 +1,118 @@
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
import React, { memo } from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType } from "../../Types/CopyJobTypes";
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
interface CopyJobDetailsProps {
job: CopyJobType;
}
const sectionCss = {
verticalAlign: { display: "flex", flexDirection: "column" } as React.CSSProperties,
headingText: { marginBottom: "10px" } as React.CSSProperties,
};
const commonProps = {
minWidth: 100,
maxWidth: 130,
styles: {
root: {
whiteSpace: "normal",
lineHeight: "1.2",
wordBreak: "break-word",
},
},
};
const getCopyJobDetailsListColumns = (): IColumn[] => {
return [
{
key: "sourcedbcol",
name: ContainerCopyMessages.sourceDatabaseLabel,
fieldName: "sourceDatabaseName",
...commonProps,
},
{
key: "sourcecol",
name: ContainerCopyMessages.sourceContainerLabel,
fieldName: "sourceContainerName",
...commonProps,
},
{
key: "targetdbcol",
name: ContainerCopyMessages.targetDatabaseLabel,
fieldName: "targetDatabaseName",
...commonProps,
},
{
key: "targetcol",
name: ContainerCopyMessages.targetContainerLabel,
fieldName: "targetContainerName",
...commonProps,
},
{
key: "statuscol",
name: ContainerCopyMessages.MonitorJobs.Columns.status,
fieldName: "jobStatus",
onRender: ({ jobStatus }: { jobStatus: CopyJobStatusType }) => <CopyJobStatusWithIcon status={jobStatus} />,
...commonProps,
},
];
};
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
const selectedContainers = [
{
sourceContainerName: job?.Source?.containerName || "N/A",
sourceDatabaseName: job?.Source?.databaseName || "N/A",
targetContainerName: job?.Destination?.containerName || "N/A",
targetDatabaseName: job?.Destination?.databaseName || "N/A",
jobStatus: job?.Status || "",
},
];
return (
<Stack className="copyJobDetailsContainer" tokens={{ childrenGap: 15 }} data-testid="copy-job-details">
{job.Error ? (
<Stack.Item data-testid="error-stack" style={sectionCss.verticalAlign}>
<Text className="bold" style={sectionCss.headingText}>
{ContainerCopyMessages.errorTitle}
</Text>
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
{job.Error.message}
</Text>
</Stack.Item>
) : null}
<Stack.Item data-testid="selectedcollection-stack">
<Stack tokens={{ childrenGap: 15 }}>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text>{job.LastUpdatedTime}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{job.Source?.remoteAccountName}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text>{job.Mode}</Text>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<DetailsList
items={selectedContainers}
layoutMode={DetailsListLayoutMode.justified}
checkboxVisibility={2}
columns={getCopyJobDetailsListColumns()}
/>
</Stack.Item>
</Stack>
);
};
export default memo(CopyJobDetails, (prevProps, nextProps) => {
return prevProps.job.ID === nextProps.job.ID && prevProps.job.Error === nextProps.job.Error;
});

View File

@@ -0,0 +1,72 @@
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import PropTypes from "prop-types";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
const theme = getTheme();
const iconClass = mergeStyles({
fontSize: "16px",
marginRight: "8px",
});
const classNames = mergeStyleSets({
[CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
[CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
[CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
});
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Pending]: "Clock",
[CopyJobStatusType.Paused]: "CirclePause",
[CopyJobStatusType.Skipped]: "StatusCircleBlock2",
[CopyJobStatusType.Cancelled]: "StatusErrorFull",
[CopyJobStatusType.Failed]: "StatusErrorFull",
[CopyJobStatusType.Faulted]: "StatusErrorFull",
[CopyJobStatusType.Completed]: "CompletedSolid",
};
export interface CopyJobStatusWithIconProps {
status: CopyJobStatusType;
}
const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo(({ status }) => {
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
const isSpinnerStatus = [
CopyJobStatusType.Running,
CopyJobStatusType.InProgress,
CopyJobStatusType.Partitioning,
].includes(status);
return (
<Stack horizontal verticalAlign="center">
{isSpinnerStatus ? (
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
) : (
<FontIcon
aria-label={status}
iconName={iconMap[status] || "UnknownSolid"}
className={classNames[status] || classNames.unknown}
/>
)}
<Text>{statusText}</Text>
</Stack>
);
});
CopyJobStatusWithIcon.displayName = "CopyJobStatusWithIcon";
CopyJobStatusWithIcon.propTypes = {
status: PropTypes.oneOf(Object.values(CopyJobStatusType)).isRequired,
};
export default CopyJobStatusWithIcon;

View File

@@ -0,0 +1,28 @@
import { ActionButton, Image } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import React from "react";
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
import * as Actions from "../../Actions/CopyJobActions";
import ContainerCopyMessages from "../../ContainerCopyMessages";
interface CopyJobsNotFoundProps {
explorer: Explorer;
}
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = ({ explorer }) => {
return (
<div className="notFoundContainer flexContainer centerContent">
<Image src={CopyJobIcon} alt={ContainerCopyMessages.noCopyJobsTitle} width={100} height={100} />
<h4 className="noCopyJobsMessage">{ContainerCopyMessages.noCopyJobsTitle}</h4>
<ActionButton
allowDisabledFocus
className="createCopyJobButton"
onClick={Actions.openCreateCopyJobPanel.bind(null, explorer)}
>
{ContainerCopyMessages.createCopyJobButtonText}
</ActionButton>
</div>
);
};
export default React.memo(CopyJobsNotFound);

View File

@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/prop-types */
import {
ConstrainMode,
DetailsListLayoutMode,
DetailsRow,
IColumn,
IDetailsRowProps,
ScrollablePane,
ScrollbarVisibility,
ShimmeredDetailsList,
Stack,
Sticky,
StickyPositionType,
} from "@fluentui/react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { getColumns } from "./CopyJobColumns";
interface CopyJobsListProps {
jobs: CopyJobType[];
handleActionClick: HandleJobActionClickType;
pageSize?: number;
}
const styles = {
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
};
const PAGE_SIZE = 10;
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
const [startIndex, setStartIndex] = React.useState(0);
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
useEffect(() => {
setSortedJobs(jobs);
setStartIndex(0);
}, [jobs]);
const handleSort = (columnKey: string) => {
const isDescending = sortedColumnKey === columnKey ? !isSortedDescending : false;
const sorted = [...sortedJobs].sort((current: any, next: any) => {
if (current[columnKey] < next[columnKey]) {
return isDescending ? 1 : -1;
}
if (current[columnKey] > next[columnKey]) {
return isDescending ? -1 : 1;
}
return 0;
});
setSortedJobs(sorted);
setSortedColumnKey(columnKey);
setIsSortedDescending(isDescending);
setStartIndex(0);
};
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const _handleRowClick = (job: CopyJobType) => {
openCopyJobDetailsPanel(job);
};
const _onRenderRow = (props: IDetailsRowProps) => {
return (
<div onClick={_handleRowClick.bind(null, props.item)}>
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
</div>
);
};
return (
<div style={styles.container}>
<Stack verticalFill={true}>
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
<ShimmeredDetailsList
onRenderRow={_onRenderRow}
checkboxVisibility={2}
columns={columns}
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
onRenderDetailsHeader={(props, defaultRender) => (
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
{defaultRender({ ...props })}
</Sticky>
)}
/>
</ScrollablePane>
</Stack.Item>
{sortedJobs.length > pageSize && (
<Stack.Item>
<Pager
disabled={false}
startIndex={startIndex}
totalCount={sortedJobs.length}
pageSize={pageSize}
onLoadPage={(startIdx /* pageSize */) => {
setStartIndex(startIdx);
}}
showFirstLast={true}
showItemCount={true}
/>
</Stack.Item>
)}
</Stack>
</div>
);
};
export default CopyJobsList;

View File

@@ -0,0 +1,12 @@
import create from "zustand";
import { MonitorCopyJobsRef } from "./MonitorCopyJobs";
type MonitorCopyJobsRefStateType = {
ref: MonitorCopyJobsRef;
setRef: (ref: MonitorCopyJobsRef) => void;
};
export const MonitorCopyJobsRefState = create<MonitorCopyJobsRefStateType>((set) => ({
ref: null,
setRef: (ref) => set({ ref: ref }),
}));

View File

@@ -0,0 +1,125 @@
/* eslint-disable react/display-name */
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
import Explorer from "Explorer/Explorer";
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
import { convertToCamelCase, isEqual } from "../CopyJobUtils";
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
import CopyJobsList from "./Components/CopyJobsList";
const FETCH_INTERVAL_MS = 30 * 1000;
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
interface MonitorCopyJobsProps {
explorer: Explorer;
}
export interface MonitorCopyJobsRef {
refreshJobList: () => void;
}
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({ explorer }, ref) => {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
const isUpdatingRef = React.useRef(false);
const isFirstFetchRef = React.useRef(true);
const fetchJobs = React.useCallback(async () => {
if (isUpdatingRef.current) {
return;
}
try {
if (isFirstFetchRef.current) {
setLoading(true);
}
setError(null);
const response = await getCopyJobs();
setJobs((prevJobs) => {
return isEqual(prevJobs, response) ? prevJobs : response;
});
} catch (error) {
setError(error.message || "Failed to load copy jobs. Please try again later.");
} finally {
if (isFirstFetchRef.current) {
setLoading(false);
isFirstFetchRef.current = false;
}
}
}, []);
useEffect(() => {
fetchJobs();
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchJobs]);
useImperativeHandle(ref, () => ({
refreshJobList: () => {
if (isUpdatingRef.current) {
setError("Please wait for the current update to complete before refreshing.");
return;
}
fetchJobs();
},
}));
const handleActionClick = React.useCallback(
async (job: CopyJobType, action: string, setUpdatingJobAction: JobActionUpdatorType) => {
try {
isUpdatingRef.current = true;
setUpdatingJobAction({ jobName: job.Name, action });
const updatedCopyJob = await updateCopyJobStatus(job, action);
if (updatedCopyJob) {
setJobs((prevJobs) =>
prevJobs.map((prevJob) =>
prevJob.Name === updatedCopyJob.properties.jobName
? {
...prevJob,
Status: convertToCamelCase(updatedCopyJob.properties.status) as CopyJobStatusType,
}
: prevJob,
),
);
}
} catch (error) {
setError(error.message || "Failed to update copy job status. Please try again later.");
} finally {
isUpdatingRef.current = false;
setUpdatingJobAction(null);
}
},
[],
);
const renderJobsList = () => {
if (loading) {
return null;
}
if (jobs.length > 0) {
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
}
return <CopyJobsNotFound explorer={explorer} />;
};
return (
<Stack className="monitorCopyJobs flexContainer">
{loading && (
<ShimmerTree indentLevels={SHIMMER_INDENT_LEVELS} style={{ width: "100%", padding: "1rem 2.5rem" }} />
)}
{error && (
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
{error}
</MessageBar>
)}
{renderJobsList()}
</Stack>
);
});
export default MonitorCopyJobs;

View File

@@ -0,0 +1,147 @@
import { DatabaseAccount, Subscription } from "Contracts/DataModels";
import React from "react";
import { ApiType } from "UserContext";
import { CosmosSqlDataTransferDataSourceSink } from "../../../Utils/arm/generatedClients/dataTransferService/types";
import Explorer from "../../Explorer";
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
export interface ContainerCopyProps {
explorer: Explorer;
}
export type CopyJobCommandBarBtnType = {
key: string;
iconSrc: string;
label: string;
ariaLabel: string;
disabled?: boolean;
onClick: () => void;
};
export type CopyJobTabForwardRefHandle = {
validate: (state: CopyJobContextState) => boolean;
};
export type DropdownOptionType = {
key: string;
text: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
};
export type DatabaseParams = [string | undefined, string | undefined, string | undefined, ApiType];
export type DataContainerParams = [
string | undefined,
string | undefined,
string | undefined,
string | undefined,
ApiType,
];
export interface DatabaseContainerSectionProps {
heading: string;
databaseOptions: DropdownOptionType[];
selectedDatabase: string;
databaseDisabled?: boolean;
databaseOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
containerOptions: DropdownOptionType[];
selectedContainer: string;
containerDisabled?: boolean;
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
handleOnDemandCreateContainer?: () => void;
}
export interface CopyJobContextState {
jobName: string;
migrationType: CopyJobMigrationType;
sourceReadAccessFromTarget?: boolean;
source: {
subscription: Subscription;
account: DatabaseAccount;
databaseId: string;
containerId: string;
};
target: {
subscriptionId: string;
account: DatabaseAccount;
databaseId: string;
containerId: string;
};
}
export interface CopyJobFlowType {
currentScreen: string;
}
export interface CopyJobContextProviderType {
contextError: string | null;
setContextError: React.Dispatch<React.SetStateAction<string | null>>;
flow: CopyJobFlowType;
setFlow: React.Dispatch<React.SetStateAction<CopyJobFlowType>>;
copyJobState: CopyJobContextState | null;
setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>;
resetCopyJobState: () => void;
explorer?: Explorer;
}
export type CopyJobType = {
ID: string;
Mode: string;
Name: string;
Status: CopyJobStatusType;
CompletionPercentage: number;
Duration: string;
LastUpdatedTime: string;
timestamp: number;
Error?: CopyJobErrorType;
Source: CosmosSqlDataTransferDataSourceSink;
Destination: CosmosSqlDataTransferDataSourceSink;
};
export interface CopyJobErrorType {
message: string;
code: string;
}
export interface CopyJobError {
message: string;
navigateToStep?: number;
}
export type DataTransferJobType = {
id: string;
type: string;
properties: {
jobName: string;
status: string;
lastUpdatedUtcTime: string;
processedCount: number;
totalCount: number;
mode: string;
duration: string;
source: {
databaseName: string;
collectionName: string;
component: string;
};
destination: {
databaseName: string;
collectionName: string;
component: string;
};
error: {
message: string;
code: string;
};
};
};
export type JobActionUpdatorType = React.Dispatch<React.SetStateAction<{ jobName: string; action: string } | null>>;
export type HandleJobActionClickType = (
job: CopyJobType,
action: string,
setUpdatingJobAction: JobActionUpdatorType,
) => void;
export type AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => boolean;

View File

@@ -0,0 +1,184 @@
@import "../../../less/Common/Constants.less";
#containerCopyWrapper {
.centerContent {
justify-content: center;
align-items: center;
}
.notFoundContainer {
.noCopyJobsMessage {
font-weight: 600;
margin: 0 auto;
color: @FocusColor;
}
button.createCopyJobButton {
color: @LinkColor;
}
}
}
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
label {
padding: 0;
}
.flex-row {
display: flex;
flex-direction: row;
label.field-label {
font-weight: 600;
}
.flex-fixed-width {
flex: 0 0 auto;
width: 150px;
}
.flex-grow-col {
flex: 1 1 auto;
}
}
.databaseContainerSection {
label.subHeading {
font: inherit;
padding: unset;
font-weight: 600;
}
}
.accordionHeader {
button {
display: flex;
align-items: center;
.accordionHeaderText {
margin-left: 5px;
font-weight: 600;
}
.statusIcon {
margin-left: auto;
}
}
}
.popover-container {
border-radius: 6px;
button[disabled] {
cursor: not-allowed;
opacity: 0.8;
}
}
.foreground {
z-index: 10;
background-color: #f9f9f9;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
position: absolute;
}
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: @LinkColor;
&:focus {
outline: none;
}
}
/* Create collection panel */
.panelFormWrapper .panelMainContent {
padding: 0;
}
.createCopyJobScreensFooter {
margin-top: 50px;
}
}
.monitorCopyJobs {
padding: 0;
width: 100%;
max-width: 100%;
margin: 0 auto;
.ms-DetailsList {
width: 100%;
.ms-DetailsHeader {
.ms-DetailsHeader-cell {
padding: @DefaultSpace 20px;
font-weight: 600;
font-size: @DefaultFontSize;
color: @BaseHigh;
background-color: @BaseLow;
border-bottom: @ButtonBorderWidth solid @BaseMedium;
&:hover {
background-color: @BaseMediumLow;
}
}
}
.ms-DetailsRow {
border-bottom: @ButtonBorderWidth solid @BaseMedium;
&:hover {
background-color: @BaseMediumLow;
}
.ms-DetailsRow-cell {
padding: @MediumSpace 20px;
font-size: @DefaultFontSize;
color: @BaseHigh;
min-height: 48px;
display: flex;
align-items: center;
.jobNameLink {
color: @LinkColor;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
}
button[role="button"] {
&.ms-Button--icon {
i.ms-Icon {
font-size: @LargeSpace;
}
}
}
}
.copyJobDetailsContainer {
padding: 1em 0 0 2em;
.ms-DetailsList {
width: 100%;
.ms-DetailsHeader-cellTitle, .ms-DetailsRow-cell {
padding-left: 0;
}
.ms-DetailsRow-cell {
font-size: @DefaultFontSize;
color: @BaseHigh;
}
}
}
.bold {
font-weight: 600;
}
.fullWidth {
width: 100%;
display: flex;
justify-content: center;
}

View File

@@ -12,6 +12,7 @@ import {
import { FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import * as React from "react";
import { isFullTextSearchPreviewFeaturesEnabled } from "Utils/CapabilityUtils";
export interface FullTextPoliciesComponentProps {
fullTextPolicy: FullTextPolicy;
@@ -22,6 +23,7 @@ export interface FullTextPoliciesComponentProps {
) => void;
discardChanges?: boolean;
onChangesDiscarded?: () => void;
englishOnly?: boolean;
}
export interface FullTextPolicyData {
@@ -66,6 +68,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
onFullTextPathChange,
discardChanges,
onChangesDiscarded,
englishOnly,
}): JSX.Element => {
const getFullTextPathError = (path: string, index?: number): string => {
let error = "";
@@ -87,6 +90,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
if (!fullTextPolicy) {
fullTextPolicy = { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] };
}
return fullTextPolicy.fullTextPaths.map((fullTextPath: FullTextPath) => ({
...fullTextPath,
pathError: getFullTextPathError(fullTextPath.path),
@@ -166,7 +170,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions()}
options={getFullTextLanguageOptions(englishOnly)}
selectedKey={defaultLanguage}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
setDefaultLanguage(option.key as never)
@@ -211,7 +215,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions()}
options={getFullTextLanguageOptions(englishOnly)}
selectedKey={fullTextPolicy.language}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onFullTextPathPolicyChange(index, option)
@@ -229,11 +233,30 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
);
};
export const getFullTextLanguageOptions = (): IDropdownOption[] => {
return [
export const getFullTextLanguageOptions = (englishOnly?: boolean): IDropdownOption[] => {
const multiLanguageSupportEnabled: boolean = isFullTextSearchPreviewFeaturesEnabled() && !englishOnly;
const fullTextLanguageOptions: IDropdownOption[] = [
{
key: "en-US",
text: "English (US)",
},
...(multiLanguageSupportEnabled
? [
{
key: "fr-FR",
text: "French",
},
{
key: "de-DE",
text: "German",
},
{
key: "es-ES",
text: "Spanish",
},
]
: []),
];
return fullTextLanguageOptions;
};

View File

@@ -24,6 +24,11 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
changeFeedPolicy: undefined,
analyticalStorageTtl: undefined,
geospatialConfig: undefined,
dataMaskingPolicy: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
indexes: [],
}),
}));
@@ -92,7 +97,6 @@ describe("SettingsComponent", () => {
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
wrapper.setState({
userCanChangeProvisioningTypes: true,
isAutoPilotSelected: true,
wasAutopilotOriginallySet: false,
autoPilotThroughput: 1000,
@@ -286,4 +290,157 @@ describe("SettingsComponent", () => {
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
});
it("should handle data masking policy updates correctly", async () => {
updateUserContext({
apiType: "SQL",
authType: AuthType.AAD,
});
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
wrapper.setState({
dataMaskingContent: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
dataMaskingContentBaseline: {
includedPaths: [],
excludedPaths: [],
isPolicyEnabled: false,
},
isDataMaskingDirty: true,
});
await settingsComponentInstance.onSaveClick();
// The test needs to match what onDataMaskingContentChange returns
expect(updateCollection).toHaveBeenCalled();
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
});
});
it("should validate data masking policy content", () => {
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
// Test with invalid data structure
// Use invalid data type for testing validation
type InvalidPolicy = Omit<DataModels.DataMaskingPolicy, "includedPaths"> & { includedPaths: string };
const invalidPolicy: InvalidPolicy = {
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
};
// Use type assertion since we're deliberately testing with invalid data
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
// State should update with the content but also set validation errors
expect(wrapper.state("dataMaskingContent")).toEqual({
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
});
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
// Test with valid data
const validPolicy = {
includedPaths: [
{
path: "/path1",
strategy: "mask",
startPosition: 0,
length: 4,
},
],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
};
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
// State should update with valid data and no validation errors
expect(wrapper.state("dataMaskingContent")).toEqual(validPolicy);
expect(wrapper.state("dataMaskingValidationErrors")).toEqual([]);
});
it("should handle data masking discard correctly", () => {
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
const baselinePolicy = {
includedPaths: [
{
path: "/basePath",
strategy: "mask",
startPosition: 0,
length: 4,
},
],
excludedPaths: ["/excludedPath1"],
isPolicyEnabled: false,
};
const modifiedPolicy = {
includedPaths: [
{
path: "/newPath",
strategy: "mask",
startPosition: 1,
length: 5,
},
],
excludedPaths: ["/excludedPath2"],
isPolicyEnabled: true,
};
// Set initial state
wrapper.setState({
dataMaskingContent: modifiedPolicy,
dataMaskingContentBaseline: baselinePolicy,
isDataMaskingDirty: true,
});
// Call revert
settingsComponentInstance.onRevertClick();
// Verify state is reset
expect(wrapper.state("dataMaskingContent")).toEqual(baselinePolicy);
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
expect(wrapper.state("shouldDiscardDataMasking")).toBe(true);
});
it("should disable save button when data masking has validation errors", () => {
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
// Initially, save button should be disabled
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
// Make data masking dirty with valid data
wrapper.setState({
isDataMaskingDirty: true,
dataMaskingValidationErrors: [],
});
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
// Add validation errors - save should be disabled
wrapper.setState({
dataMaskingValidationErrors: ["includedPaths must be an array"],
});
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
// Clear validation errors - save should be enabled again
wrapper.setState({
dataMaskingValidationErrors: [],
});
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});

View File

@@ -1,4 +1,6 @@
import { IPivotItemProps, IPivotProps, Pivot, PivotItem } from "@fluentui/react";
import { sendMessage } from "Common/MessageHandler";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import {
ComputedPropertiesComponent,
ComputedPropertiesComponentProps,
@@ -13,7 +15,7 @@ import {
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
@@ -45,6 +47,7 @@ import {
ConflictResolutionComponent,
ConflictResolutionComponentProps,
} from "./SettingsSubComponents/ConflictResolutionComponent";
import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent";
import {
GlobalSecondaryIndexComponent,
GlobalSecondaryIndexComponentProps,
@@ -149,6 +152,12 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean;
dataMaskingContent: DataModels.DataMaskingPolicy;
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
shouldDiscardDataMasking: boolean;
isDataMaskingDirty: boolean;
dataMaskingValidationErrors: string[];
selectedTab: SettingsV2TabTypes;
}
@@ -256,6 +265,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
shouldDiscardComputedProperties: false,
isComputedPropertiesDirty: false,
dataMaskingContent: undefined,
dataMaskingContentBaseline: undefined,
shouldDiscardDataMasking: false,
isDataMaskingDirty: false,
dataMaskingValidationErrors: [],
conflictResolutionPolicyMode: undefined,
conflictResolutionPolicyModeBaseline: undefined,
conflictResolutionPolicyPath: undefined,
@@ -332,7 +347,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
public isSaveSettingsButtonEnabled = (): boolean => {
if (this.isOfferReplacePending()) {
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
return false;
}
@@ -340,6 +355,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
return false;
}
if (this.state.dataMaskingValidationErrors.length > 0) {
return false;
}
return (
this.state.isScaleSaveable ||
this.state.isSubSettingsSaveable ||
@@ -347,12 +366,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
this.state.isThroughputBucketsSaveable
);
};
public isDiscardSettingsButtonEnabled = (): boolean => {
if (this.props.settingsTab.isExecuting()) {
return false;
}
return (
this.state.isScaleDiscardable ||
this.state.isSubSettingsDiscardable ||
@@ -360,6 +383,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
this.state.isThroughputBucketsSaveable
);
@@ -415,7 +439,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
: this.saveDatabaseSettings(startKey));
} catch (error) {
this.props.settingsTab.isExecutionError(true);
console.error(error);
traceFailure(
Action.SettingsV2Updated,
{
@@ -431,10 +454,20 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
);
} finally {
this.props.settingsTab.isExecuting(false);
if (isFabricNative() && this.isCollectionSettingsTab) {
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
params: { updateType: "settings" },
});
}
}
};
public onRevertClick = (): void => {
if (this.props.settingsTab.isExecuting()) {
return;
}
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
message: "Settings Discarded",
});
@@ -475,6 +508,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
shouldDiscardComputedProperties: true,
isComputedPropertiesDirty: false,
dataMaskingContent: this.state.dataMaskingContentBaseline,
shouldDiscardDataMasking: true,
isDataMaskingDirty: false,
dataMaskingValidationErrors: [],
});
};
@@ -637,6 +674,36 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
if (!newDataMasking.excludedPaths) {
newDataMasking.excludedPaths = [];
}
if (!newDataMasking.includedPaths) {
newDataMasking.includedPaths = [];
}
const validationErrors = [];
if (!Array.isArray(newDataMasking.includedPaths)) {
validationErrors.push("includedPaths must be an array");
}
if (!Array.isArray(newDataMasking.excludedPaths)) {
validationErrors.push("excludedPaths must be an array");
}
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
validationErrors.push("isPolicyEnabled must be a boolean");
}
this.setState({
dataMaskingContent: newDataMasking,
dataMaskingValidationErrors: validationErrors,
});
};
private resetShouldDiscardDataMasking = (): void => this.setState({ shouldDiscardDataMasking: false });
private onDataMaskingDirtyChange = (isDataMaskingDirty: boolean): void =>
this.setState({ isDataMaskingDirty: isDataMaskingDirty });
private calculateTotalThroughputUsed = (): void => {
this.totalThroughputUsed = 0;
(useDatabases.getState().databases || []).forEach(async (database) => {
@@ -761,6 +828,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const fullTextPolicy: DataModels.FullTextPolicy =
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
const indexingPolicyContent = this.collection.indexingPolicy();
const dataMaskingContent: DataModels.DataMaskingPolicy = {
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled ?? true,
};
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
@@ -813,11 +885,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
geospatialConfigTypeBaseline: geoSpatialConfigType,
computedPropertiesContent: computedPropertiesContent,
computedPropertiesContentBaseline: computedPropertiesContent,
dataMaskingContent: dataMaskingContent,
dataMaskingContentBaseline: dataMaskingContent,
};
};
private getTabsButtons = (): CommandButtonComponentProps[] => {
const buttons: CommandButtonComponentProps[] = [];
const isExecuting = this.props.settingsTab.isExecuting();
if (this.saveSettingsButton.isVisible()) {
const label = "Save";
buttons.push({
@@ -827,7 +902,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: !this.saveSettingsButton.isEnabled(),
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
});
}
@@ -836,11 +911,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
onCommandClick: this.onRevertClick,
onCommandClick: () => {
if (isExecuting) {
return;
}
this.onRevertClick();
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: !this.discardSettingsChangesButton.isEnabled(),
disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(),
});
}
return buttons;
@@ -938,7 +1018,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty
) {
let defaultTtl: number;
switch (this.state.timeToLive) {
@@ -961,6 +1042,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
newCollection.fullTextPolicy = this.state.fullTextPolicy;
// Only send data masking policy if it was modified (dirty)
if (this.state.isDataMaskingDirty && isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
}
newCollection.indexingPolicy = this.state.indexingPolicyContent;
newCollection.changeFeedPolicy =
@@ -1006,13 +1092,18 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
await this.refreshIndexTransformationProgress();
}
// Update collection object with new data
this.collection.dataMaskingPolicy(updatedCollection.dataMaskingPolicy);
this.setState({
dataMaskingContentBaseline: this.state.dataMaskingContent,
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
isComputedPropertiesDirty: false,
isDataMaskingDirty: false,
});
}
@@ -1342,6 +1433,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
// Check if DDM should be enabled
const shouldEnableDDM = (): boolean => {
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
const isSqlAccount = userContext.apiType === "SQL";
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
};
if (shouldEnableDDM()) {
const dataMaskingComponentProps: DataMaskingComponentProps = {
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,
dataMaskingContent: this.state.dataMaskingContent,
dataMaskingContentBaseline: this.state.dataMaskingContentBaseline,
onDataMaskingContentChange: this.onDataMaskingContentChange,
onDataMaskingDirtyChange: this.onDataMaskingDirtyChange,
validationErrors: this.state.dataMaskingValidationErrors,
};
tabs.push({
tab: SettingsV2TabTypes.DataMaskingTab,
content: <DataMaskingComponent {...dataMaskingComponentProps} />,
});
}
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({
tab: SettingsV2TabTypes.ThroughputBucketsTab,

View File

@@ -69,13 +69,111 @@ describe("SettingsUtils functions", () => {
expect(wrapper).toMatchSnapshot();
});
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
const prices = getRuPriceBreakdown(500, "", 1, false, false);
expect(prices.hourlyPrice).toBe(0.04);
expect(prices.dailyPrice).toBe(0.96);
expect(prices.monthlyPrice).toBe(29.2);
expect(prices.pricePerRu).toBe(0.00008);
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
describe("getRuPriceBreakdown", () => {
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
const prices = getRuPriceBreakdown(500, "", 1, false, false);
expect(prices.hourlyPrice).toBe(0.04);
expect(prices.dailyPrice).toBe(0.96);
expect(prices.monthlyPrice).toBe(29.2);
expect(prices.pricePerRu).toBe(0.00008);
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
it("should return correct price breakdown for autoscale", () => {
const prices = getRuPriceBreakdown(1000, "", 1, false, true);
// For autoscale, the baseline RU is 10% of max RU
expect(prices.hourlyPrice).toBe(0.12); // Higher because autoscale pricing is different
expect(prices.dailyPrice).toBe(2.88); // hourlyPrice * 24
expect(prices.monthlyPrice).toBe(87.6); // hourlyPrice * 730
expect(prices.pricePerRu).toBe(0.00012); // Autoscale price per RU
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
it("should return correct price breakdown for multimaster", () => {
const prices = getRuPriceBreakdown(500, "", 2, true, false);
// For multimaster with 2 regions, price is multiplied by 4
expect(prices.hourlyPrice).toBe(0.16); // Base price * 4
expect(prices.dailyPrice).toBe(3.84); // hourlyPrice * 24
expect(prices.monthlyPrice).toBe(116.8); // hourlyPrice * 730
expect(prices.pricePerRu).toBe(0.00016); // Base price per RU * 2 (regions) * 2 (multimaster)
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
});
describe("message formatting", () => {
it("should format throughput apply delayed message correctly", () => {
const message = getThroughputApplyDelayedMessage(false, 1000, "RU/s", "testDb", "testColl", 2000);
const wrapper = shallow(message);
const text = wrapper.text();
expect(text).toContain("testDb");
expect(text).toContain("testColl");
expect(text).toContain("Current manual throughput: 1000 RU/s");
expect(text).toContain("Target manual throughput: 2000");
});
it("should format autoscale throughput message correctly", () => {
const message = getThroughputApplyDelayedMessage(true, 1000, "RU/s", "testDb", "testColl", 2000);
const wrapper = shallow(message);
const text = wrapper.text();
expect(text).toContain("Current autoscale throughput: 100 - 1000 RU/s");
expect(text).toContain("Target autoscale throughput: 200 - 2000 RU/s");
});
});
describe("estimated spending element", () => {
// Mock Stack component since we're using shallow rendering
const mockStack = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
beforeEach(() => {
jest.mock("@fluentui/react", () => ({
...jest.requireActual("@fluentui/react"),
Stack: mockStack,
}));
});
afterEach(() => {
jest.resetModules();
});
it("should render correct spending info for manual throughput", () => {
const costElement = <div>Cost</div>;
const priceBreakdown: PriceBreakdown = {
hourlyPrice: 1.0,
dailyPrice: 24.0,
monthlyPrice: 730.0,
pricePerRu: 0.0001,
currency: "USD",
currencySign: "$",
};
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, false);
const wrapper = shallow(element);
const spendElement = wrapper.find("#throughputSpendElement");
expect(spendElement.find("span").at(0).text()).toBe("1 region");
expect(spendElement.find("span").at(1).text()).toBe("1000 RU/s");
expect(spendElement.find("span").at(2).text()).toBe("$0.0001/RU");
});
it("should render correct spending info for autoscale throughput", () => {
const costElement = <div>Cost</div>;
const priceBreakdown: PriceBreakdown = {
hourlyPrice: 1.0,
dailyPrice: 24.0,
monthlyPrice: 730.0,
pricePerRu: 0.0001,
currency: "USD",
currencySign: "$",
};
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, true);
const wrapper = shallow(element);
const spendElement = wrapper.find("#throughputSpendElement");
expect(spendElement.find("span").at(1).text()).toBe("100 RU/s - 1000 RU/s");
});
});
});

Some files were not shown because too many files have changed in this diff Show More