Compare commits

..

50 Commits

Author SHA1 Message Date
Asier Isayas
f304600eca legacy mongo shell debug 2025-05-28 12:24:26 -04:00
asier-isayas
34edd96c76 Default New Global Secondary Index Panel to be sharded (#2138)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-05-12 13:12:20 -04:00
JustinKol
7c0aae6ffa Open VS Code via Data Explorer button (#2116)
* master pull

* Reverting .npmrc file

* Removed logging userContext

* Prettier run

* Added support for opening CosmosDB Account without clicking database tab

* Reverting change in settings.json

* Prettier run

* Added check if the link closed

* Added check if the link didn't closed

* Check if VS Code was opened, if not popup with download button link

* Prettier run

* Redirect to Download VS Code if not opened

* Added error message to VS Code timeout and redirect

* Fixing baseUrl from testing

* Increased timeout for when user is asked to open VS Code

* switched to iframe for redirects

* Fixed VS Code url

* Removed insider url

* Added log messages

* Added link to vCore data explorer dashboard

* Increased timeout to 2.5 secs to see if that helps with VS Code open popup

* Changed to dialog box

* Changed param name

* Increase startTime for extra popup

* Changed to dialog box only when no VS Code detected

* Fixed vscode url

* Changed title back to Open CosmosDB in VS Code

* Added text on required extensions

* Removed text on required extensions as it will prompt by default

* Fixed wording and Primary Button timeout

* Spelled out VS Code

* Removed console log of timeout

* Updated snapshots and lowered timeout

* Remove VS Code button from Gremlin

* Prettier run on CommandBarComponentButtonFactory

* Changed from referencing location to a link

* Prettier run

* Reverting back to popup for opening

* Updated unit test snapshots

* Added vscode: to Content Security Policy

* Reverting back to popup only if opening times out
2025-05-12 10:55:06 -04:00
JustinKol
86e8bf3c80 Show unique keys in Settings for SQL api (#2136)
* master pull

* Added unique keys in Settings for SQL api

* Revert settings.json

* Reverting other PR changes that haven't merged

* Adding space back in

* Added unit tests
2025-05-09 12:37:56 -04:00
Dmitry Shilov
e98c9a83b8 fix: Add collapsible feature to Accordion in SettingsPane (#2125) 2025-05-09 12:13:17 +02:00
Dmitry Shilov
7d57a90d50 fix: Correct documentId method call in error logging for deletion failures (#2132) 2025-05-09 12:12:32 +02:00
Dmitry Shilov
0f896f556b feat: Enhance UploadItemsPane with error handling and status icons for file uploads (#2133) 2025-05-09 12:11:26 +02:00
sunghyunkang1111
985c744198 get the user defined system key value for updating (#2134)
* get the user defined system key value for updating

* Added the systemkey check for non-defined system key
2025-05-08 09:14:09 -05:00
Sourabh Jain
2dbec019af CloudShell: Changed User Consent Message and Add appName in commands (#2130)
* message change

* updated commands
2025-05-08 06:41:35 +05:30
Laurent Nguyen
2fa95a281e Disable "Learn more" link for now in Fabric Home (#2129) 2025-05-07 11:52:41 +02:00
Sourabh Jain
ea6f3d1579 Cloudshell: Few Enhancement (#2128)
* few enhancement

* fix time
2025-05-05 21:17:36 +05:30
Dmitry Shilov
f9b0abdd14 fix: Add overflow property and set minimum heights for flex and sidebar containers (#2124)
* fix: Add overflow property and set minimum heights for flex and sidebar containers

* fix: Update overflow and minimum height properties for tab panes and containers
2025-05-05 15:50:43 +02:00
asier-isayas
10cda21401 Add Vector Index Shard Key option on container creation (#2097)
* Add vector index shard key

* npm run format

* rename shard key to vector index shard key

* add tooltip for quantization byte size

* change text for GSI and container in VectorEmbedding Policy

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-05-02 11:05:40 -04:00
Sourabh Jain
205355bf55 Shell: Integrate Cloudshell to existing shells (#2098)
* first draft

* refactored code

* ux fix

* add custom header support and fix ui

* minor changes

* hide last command also

* remove logger

* bug fixes

* updated loick file

* fix tests

* moved files

* update readme

* documentation update

* fix compilationerror

* undefined check handle

* format fix

* format fix

* fix lints

* format fix

* fix unrelatred test

* code refator

* fix format

* ut fix

* cgmanifest

* Revert "cgmanifest"

This reverts commit 2e76a6926ee0d3d4e0510f2e04e03446c2ca8c47.

* fix snap

* test fix

* formatting code

* updated xterm

* include username in command

* cloudshell add exit

* fix test

* format fix

* tets fix

* fix multiple open cloudshell calls

* socket time out after 20 min

* remove unused code

* 120 min

* Addressed comments
2025-04-30 13:19:01 -07:00
sunghyunkang1111
bb66deb3a4 Added more test cases and fix system partition key load issue (#2126)
* Added more test cases and fix system partition key load issue

* Fix unit tests and fix ci

* Updated test snapsho
2025-04-30 15:18:11 -05:00
Laurent Nguyen
fe73d0a1c6 Fix Fabric Native ReadOnly mode (#2123)
* Add FabricNativeReadOnly mode

* Hide Settings for Fabric native readonly

* Fix strict compil
2025-04-30 17:37:54 +02:00
sakshigupta12feb
e90e1fc581 Updated the Migrate data link (#2122)
* updated the Migrate data link

* updated the Migrate data link (removed en-us)

---------

Co-authored-by: Sakshi Gupta <sakshig+microsoft@microsoft.com>
2025-04-30 17:48:15 +05:30
Nishtha Ahuja
8bcad6e0e0 Emulator Quickstart Tutorials (#2121)
* updated all outdated sample apps
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-30 13:32:53 +05:30
SATYA SB
9f3236c29c [accessibility-3560183]:[Screen reader - Cosmos DB Query Copilot - Query Faster with Copilot>Enable Query Advisor]: Screen reader does not announce the dialog information on invoking 'Clear editor' button. (#2068)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-04-30 11:35:58 +05:30
Laurent Nguyen
2f858ecf9b Fabric native improvements: Settings pane, Partition Key settings tab, sample data and message contract (#2119)
* Hide entire Accordion of options in Settings Pane

* In PartitionKeyComponent hide "Change partition key" label when read-only.

* Create sample data container with correct pkey

* Add unit tests to PartitionKeyComponent

* Fix format

* fix unit test snapshot

* Add Fabric message to open Settings to given tab id

* Improve syntax on message contract

* Remove "(preview)" in partition key tab title in Settings Tab
2025-04-29 17:50:20 +02:00
sunghyunkang1111
274c85d2de Added document test skips (#2120) 2025-04-28 13:42:18 -05:00
asier-isayas
d9436be61b Remove references to old Portal Backend (#2109)
* remove old portal backend endpoints

* format

* fix tests

* remove Materialized Views from createResourceTokenTreeNodes

* add portal FE back to defaultAllowedBackendEndpoints

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-28 13:29:27 -04:00
Nishtha Ahuja
6db2536a61 fixed quickstart tab in emulator (#2115)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-28 21:11:27 +05:30
Sourabh Jain
714f38a1be Mongo RU Schema Analyzer Deprecation (#2117)
* remove menu item

* remove unused import
2025-04-27 20:43:24 -05:00
asier-isayas
af4e1d10b4 GSI: Remove Unique Key Policy and Manual Throughput (#2114)
* Remove Unique Key Policy and Manual Throughput

* fix tests

* remove manual throughput option from scale & settings

* fix test

* cleanup

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-18 14:39:31 -04:00
Laurent Nguyen
6dbc412fa6 Implement Sample data import for Fabric Home (#2101)
* Implement dialog to import sample data

* Fix format

* Cosmetic fixes

* fix: update help link to point to the new documentation URL

---------

Co-authored-by: Sevo Kukol <sevoku@microsoft.com>
2025-04-16 14:27:22 -07:00
sunghyunkang1111
3470f56535 Pk missing fix (#2107)
* fix partition key missing not being able to load the document

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Updated snapshot

* Updated tests for MongoRU and add create/delete tests

* Fixing system partition key showing up in Data Explorer
2025-04-16 13:12:53 -05:00
Vsevolod Kukol
00ec678569 fix: handle optional activeTab in tab activation logic (#2106) 2025-04-16 10:34:15 -07:00
Laurent Nguyen
27c9ea7ab6 Add tracing events for tracking query execution and upload documents (#2100)
* Add tracing for Execute (submit) query and Upload documents

* Trace query closer to iterator operation

* Add warnings to values used in Fabric

* Fix format

* Don't trace execute queries for filtering calls

* Remove tracing call for documents tab filtering

* Fix failing unit test.

---------

Co-authored-by: Jade Welton <jawelton@microsoft.com>
Co-authored-by: Sevo Kukol <sevoku@microsoft.com>
2025-04-16 10:31:43 -07:00
asier-isayas
d5fe2d9e9f Fix Unit and E2E tests (#2112)
* fix tests

* remove Materialized Views from createResourceTokenTreeNodes

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-16 10:05:36 -04:00
Laurent Nguyen
94b1e729d1 Enable original azure resource tree style for Fabric native and turn on Settings tab (#2103)
* Enable original azure resource tree for Fabric native and turn on Settings page

* Fix unit tests
2025-04-15 08:49:16 -07:00
Laurent Nguyen
afdbefe36c Implement refreshResourceTree message and hide refresh button above resource tree for Fabric native (#2102) 2025-04-15 08:48:44 -07:00
asier-isayas
2cff0fc3ff Global Secondary Index (#2071)
* add Materialized Views feature flag

* fetch MV properties from RP API and capture them in our data models

* AddMaterializedViewPanel

* undefined check

* subpartition keys

* Partition Key, Throughput, Unique Keys

* All views associated with a container (#2063) and Materialized View Target Container (#2065)

Identified Source container and Target container
Created tabs in Scale and Settings respectively
Changed the Icon of target container

* Add MV Panel

* format

* format

* styling

* add tests

* tests

* test files (#2074)

Co-authored-by: nishthaAhujaa

* fix type error

* fix tests

* merge conflict

* Panel Integration (#2075)

* integrated panel

* edited header text

---------

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* updated tests (#2077)

Co-authored-by: nishthaAhujaa

* fix tests

* update treeNodeUtil test snap

* update settings component test snap

* fixed source container in global "New Materialized View"

* source container check (#2079)

Co-authored-by: nishthaAhujaa

* renamed Materialized Views to Global Secondary Index

* more renaming

* fix import

* fix typo

* disable materialized views for Fabric

* updated input validation

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
Co-authored-by: Nishtha Ahuja <45535788+nishthaAhujaa@users.noreply.github.com>
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-11 10:39:32 -04:00
sunghyunkang1111
a3bfc89318 Revert "Pk missing fix (#2094)" (#2099)
This reverts commit af0a890516.
2025-04-09 13:04:44 -05:00
Ajay Parulekar
0666e11d89 Renaming Materialized views builder blade text to Global secondary indexes for NoSql API (#1991)
* GSI changes

* GSI changes

* GSI changes

* updating GlobalSecondaryIndexesBuilder.json

* Changes

* Update cost text keys based on user context API type

* Refactor Materialized Views Builder code for improved readability and consistency in API type checks

* Update links in Materialized Views Builder for consistency and accuracy

* Update Global Secondary Indexes links and descriptions for clarity and accuracy based on API type

* Update portal notification message keys based on user context API type for Materialized Views Builder

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Fix capitalization and wording inconsistencies in Materialized Views Builder localization strings

* Fix capitalization and wording inconsistencies in localization strings for Materialized Views Builder

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

---------

Co-authored-by: Justine Cocchi <justine@cocchi.org>
2025-04-09 13:32:35 -04:00
bogercraig
9bb1d0bace Manual Region Selection (#2037)
* Add standin region selection to settings menu.

* Retrieve read and write regions from user context and populate dropdown menu.  Update local storage value.
Need to now connect with updating read region of primary cosmos client.

* Change to only selecting region for cosmos client.  Not setting up separate read and write clients.

* Add read and write endpoint logging to cosmos client.

* Pass changing endpoint from settings menu to client.  Encountered token issues using new endpoint in client.

* Rough implementation of region selection of endpoint for cosmos client.  Still need to:
1 - Use separate context var to track selected region.  Directly updating database account context throws off token generation by acquireMSALTokenForAccount
2 - Remove href overrides in acquireMSALTokenForAccount.

* Update region selection to include global endpoint and generate a unique list of read and write endpoints.
Need to continue with clearing out selected endpoint when global is selected again.
Write operations stall when read region is selected even though 403 returned when region rejects operation.
Need to limit feature availablility to nosql, table, gremlin (maybe).

* Update cosmos client to fix bug.
Clients continuously generate after changing RBAC setting.

* Swapping back to default endpoint value.

* Rebase on client refresh bug fix.

* Enable region selection for NoSql, Table, Gremlin

* Add logic to reset regional endpoint when global is selected.

* Fix state changing when selecting region or resetting to global.

* Rough implementation of configuring regional endpoint when DE is loaded in portal or hosted with AAD/Entra auth.

* Ininitial attempt at adding error handling, but still having issues with errors caught at proxy plugin.

* Added rough error handling in local requestPlugin used in local environments.  Passes new error to calling code.
Might need to add specific error handling for request plugin to the handleError class.

* Change how request plugin returns error so existing error handling utility can process and present error.

* Only enable region selection for nosql accounts.

* Limit region selection to portal and hosted AAD auth.  SQL accounts only.  Could possibly enable on table and gremlin later.

* Update error handling to account for generic error code.

* Refactor error code extraction.

* Update test snapshots and remove unneeded logging.

* Change error handling to use only the message rather than casting to any.

* Clean up debug logging in cosmos client.

* Remove unused storage keys.

* Use endpoint instead of region name to track selected region.  Prevents having to do endpoint lookups.

* Add initial button state update depending on region selection.
Need to update with the API and react to user context changes.

* Disable CRUD buttons when read region selected.

* Default to write enabled in react.

* Disable query saving when read region is selected.

* Patch clientWidth error on conflicts tab.

* Resolve merge conflicts from rebase.

* Make sure proxy endpoints return in all cases.

* Remove excess client logging and match main for ConflictsTab.

* Cleaning up logging and fixing endpoint discovery bug.

* Fix formatting.

* Reformatting if statements with preferred formatting.

* Migrate region selection to local persistence.  Fixes account swapping bug.
TODO: Inspect better way to reset interface elements when deleteAllStates is called.  Need to react to regional endpoint being reset.

* Relocate resetting interface context to helper function.

* Remove legacy state storage for regional endpoint selection.

* Laurent suggestion updates.
2025-04-07 09:29:11 -07:00
sunghyunkang1111
af0a890516 Pk missing fix (#2094)
* fix partition key missing not being able to load the document

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Updated snapshot
2025-04-07 10:45:29 -05:00
bogercraig
e3c3a8b1b7 Hide Keys and Connection Strings in Connect Tab (#2095)
* Hide connection strings and keys by default.  Move URI above pivot since common across tabs.  Matches frontend.  Need to add scrolling of keys when window is small.  Possibly reduce URI width.

* Add vertical scrolling when window size reduces.

* Adding missing semicolon at end of connection strings.
2025-04-03 17:19:28 -07:00
sunghyunkang1111
0f6c979268 Update cleanupDBs.js (#2093) 2025-04-03 11:10:40 -05:00
asier-isayas
32576f50d3 Self Serve text render fix (#2088)
* debug

* added comment

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-03-27 14:17:06 -04:00
sunghyunkang1111
10f5a5fbfe Revert "fix partition key missing not being able to load the document (#2085)" (#2090)
This reverts commit 257256f915.
2025-03-27 12:47:14 -05:00
JustinKol
8eb53674dc Add refresh button to Mongo DB RU and adjust ellipsis so refresh button on single column container doesn't hide it (#2089)
* Moved ellipsis to the left for single column containers

* Added refresh to MongoDB RU

* prettier run
2025-03-27 13:44:22 -04:00
sunghyunkang1111
257256f915 fix partition key missing not being able to load the document (#2085) 2025-03-26 11:26:47 -05:00
jawelton74
41f5401016 Fix input validation patterns for resource ids (#2086)
* Fix input element pattern matching and add validation reporting for
cases where the element is not within a form element.

* Update test snapshots.

* Remove old code and fix trigger error message.

* Move id validation to a util class.

* Add unit tests, fix standalone function, rename constants.
2025-03-26 07:10:47 -07:00
Laurent Nguyen
a4c9a47d4e Add comments for expired token used in test (#2084) 2025-03-26 09:00:55 +01:00
JustinKol
c43132d5c0 Adding container item refresh button back to upper right corner of page (#2083)
* Moved button to upper right

* Reverted background color

* Updated test snapshot

* Added hidding refresh button on overflow

* Ran prettier and updated snapshot
2025-03-25 08:16:39 -04:00
tarazou9
6ce81099ef Handle catalog empty (#2082)
Handle UI errors caused by Catalog API calls returning no offering id.
2025-03-21 16:15:48 -04:00
Nishtha Ahuja
777e411f4f edited screenshot for vcore quickstart shell (#2080)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-03-20 21:55:03 +05:30
Laurent Nguyen
63d4b4f4ef fix tab wrapping with a lil' css tweak (#2013) (#2076)
Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
2025-03-17 11:51:59 +01:00
asier-isayas
eaf9a14e7d Cancel Phoenix container allocation on ctrl+c & ctrl+z (#2055)
* Cancel Phoenix container allocation on ctrl+c

* revert package-lock

* fix build issues

* add ctrl+z

* Close terminal when Ctrl key is pressed

* format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-03-13 14:56:11 -04:00
163 changed files with 43622 additions and 3828 deletions

View File

@@ -164,24 +164,24 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npx playwright install --with-deps
- name: "Az CLI login"
uses: Azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

8
images/golang.svg Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path fill="#8CC5E7" d="M21.4679537,3.20617761 C22.1814672,4.67953668 20.0131274,4.83706564 20.1243243,5.49498069 C20.3281853,6.68108108 20.1891892,8.44169884 20.0316602,10.1745174 C19.7629344,13.1119691 21.9590734,20.1451737 17.3814672,22.9714286 C16.5196911,23.5088803 14.4718147,23.8054054 12.4517375,23.8517375 C12.4517375,23.8517375 12.442471,23.8517375 12.442471,23.8517375 C12.442471,23.8517375 12.4332046,23.8517375 12.4332046,23.8517375 C10.4131274,23.8054054 8.08725869,23.5088803 7.22548263,22.9714286 C2.65714286,20.1451737 4.85328185,13.1119691 4.59382239,10.1745174 C4.42702703,8.44169884 4.28803089,6.68108108 4.5011583,5.49498069 C4.61235521,4.83706564 2.44401544,4.68880309 3.15752896,3.20617761 C3.76911197,1.93667954 5.27953668,3.05791506 5.65945946,2.65945946 C7.596139,0.648648649 9.94980695,0.111196911 11.8030888,0.0648648649 C11.988417,0.0648648649 12.8223938,0.0648648649 12.8223938,0.0648648649 C14.6664093,0.157528958 17.0200772,0.657915058 18.9660232,2.65945946 C19.3459459,3.05791506 20.8471042,1.93667954 21.4679537,3.20617761 Z M11.4324324,10.9065637 C11.3490347,10.9436293 11.2100386,11.8517375 11.6362934,11.8980695 C11.9235521,11.9258687 12.7111969,12.0185328 12.8965251,11.8980695 C13.2579151,11.6664093 13.2208494,11.1104247 13.0169884,10.9714286 C12.6741313,10.7490347 11.5250965,10.8602317 11.4324324,10.9065637 Z M9.07876448,4.10501931 C8.12432432,3.99382239 6.52123552,4.88339768 6.28030888,6.77374517 C6.02084942,8.73822394 8.33745174,10.6841699 10.56139,8.73822394 C11.7567568,7.69111969 12.1737452,4.46640927 9.07876448,4.10501931 Z M15.5281853,4.10501931 C12.4332046,4.46640927 12.8501931,7.69111969 14.0455598,8.73822394 C16.2694981,10.6841699 18.5861004,8.73822394 18.3266409,6.77374517 C18.0949807,4.88339768 16.4918919,3.99382239 15.5281853,4.10501931 Z"/>
<path fill="#B8937F" d="M12.3127413,8.98841699 C12.8965251,8.90501931 14.2957529,9.57220077 14.2030888,10.3598456 C14.0918919,11.2772201 10.5984556,11.3976834 10.4131274,10.3042471 C10.3019305,9.63706564 10.8301158,9.21081081 12.3127413,8.98841699 Z M20.1984556,16.3737452 C19.9111969,16.3644788 19.7258687,15.984556 19.7258687,15.7528958 C19.7258687,15.3359073 19.7814672,14.8447876 20.0872587,14.6316602 C20.7173745,14.196139 21.2177606,16.3830116 20.1984556,16.3737452 Z M4.41776062,16.3737452 C3.3984556,16.3830116 3.8988417,14.196139 4.52895753,14.6316602 C4.83474903,14.8447876 4.89034749,15.3359073 4.89034749,15.7528958 C4.89034749,15.984556 4.70501931,16.3644788 4.41776062,16.3737452 Z M18.2617761,23.0918919 C18.4471042,23.3606178 18.4563707,23.5459459 18.1598456,23.6849421 C17.0293436,24.203861 16.019305,23.5088803 16.3992278,23.3142857 C17.2054054,22.9065637 17.7057915,22.2671815 18.2617761,23.0918919 Z M6.35444015,23.184556 C6.91042471,22.3598456 7.41081081,22.9992278 8.21698842,23.4069498 C8.5969112,23.6015444 7.58687259,24.2965251 6.45637066,23.7776062 C6.15984556,23.63861 6.16911197,23.4532819 6.35444015,23.184556 Z"/>
<path fill="#000000" d="M19.7351351,3.42857143 C19.7814672,3.23397683 20.2633205,3.14131274 20.5320463,3.47490347 C20.8563707,3.87335907 20.0594595,4.42007722 20.0223938,4.1976834 C19.9297297,3.5953668 19.6795367,3.62316602 19.7351351,3.42857143 Z M4.88108108,3.42857143 C4.93667954,3.62316602 4.68648649,3.5953668 4.59382239,4.1976834 C4.55675676,4.42007722 3.75984556,3.87335907 4.08416988,3.47490347 C4.34362934,3.14131274 4.82548263,3.23397683 4.88108108,3.42857143 Z M15.7413127,7.94131274 C15.1578953,7.94131274 14.6849421,7.46835949 14.6849421,6.88494208 C14.6849421,6.30152468 15.1578953,5.82857143 15.7413127,5.82857143 C16.3247301,5.82857143 16.7976834,6.30152468 16.7976834,6.88494208 C16.7976834,7.46835949 16.3247301,7.94131274 15.7413127,7.94131274 Z M15.4633205,6.76447876 C15.6475575,6.76447876 15.7969112,6.61512511 15.7969112,6.43088803 C15.7969112,6.24665096 15.6475575,6.0972973 15.4633205,6.0972973 C15.2790834,6.0972973 15.1297297,6.24665096 15.1297297,6.43088803 C15.1297297,6.61512511 15.2790834,6.76447876 15.4633205,6.76447876 Z M11.3583012,9.43320463 C11.4694981,9.00694981 11.8586873,8.86795367 12.1737452,8.85868726 C12.9799228,8.84015444 13.2857143,9.27567568 13.3135135,9.61853282 C13.369112,10.2023166 11.1081081,10.3413127 11.3583012,9.43320463 Z M8.87490347,7.94131274 C8.29148607,7.94131274 7.81853282,7.46835949 7.81853282,6.88494208 C7.81853282,6.30152468 8.29148607,5.82857143 8.87490347,5.82857143 C9.45832088,5.82857143 9.93127413,6.30152468 9.93127413,6.88494208 C9.93127413,7.46835949 9.45832088,7.94131274 8.87490347,7.94131274 Z M9.15289575,6.76447876 C9.33713283,6.76447876 9.48648649,6.61512511 9.48648649,6.43088803 C9.48648649,6.24665096 9.33713283,6.0972973 9.15289575,6.0972973 C8.96865868,6.0972973 8.81930502,6.24665096 8.81930502,6.43088803 C8.81930502,6.61512511 8.96865868,6.76447876 9.15289575,6.76447876 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

10
images/springboot.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M38.9437824,35.879008 C89.5234256,-13.1200214 170.398168,-11.8028432 219.397197,39.0402357 C224.929346,31.6640377 229.671187,23.4975328 233.095851,15.0675923 C249.165425,64.0666217 258.912543,105.162582 255.224444,137.038295 C253.380395,163.90873 242.842969,189.725423 225.456217,210.273403 C180.145286,264.014274 99.53398,270.863601 45.7931091,225.55267 L45.7931091,225.55267 L44.765,224.638 L44.7103323,224.601984 C44.5420247,224.484832 44.376007,224.362668 44.2124952,224.235492 C43.7219599,223.853965 43.2765312,223.438607 42.8762093,222.995252 L42.732,222.831 L41.0512675,221.3377 C39.4121124,219.93271 37.7729573,218.52772 36.3188215,216.93771 L35.7825547,216.332423 C-13.2164747,165.752779 -11.6358609,84.8780374 38.9437824,35.879008 Z M57.9111486,207.375611 C53.169307,203.687512 46.3199803,204.214383 42.6318814,208.956225 C39.3888978,213.125775 39.4048731,218.924805 42.6798072,222.771269 L42.732,222.831 L44.765,224.638 L44.9644841,224.773953 C49.5691585,227.80174 55.7644273,227.175885 59.2982065,222.896387 L59.4917624,222.654878 C63.1798614,217.913037 62.3895545,211.06371 57.9111486,207.375611 Z M231.778672,28.2393744 C218.60689,55.9001168 185.940871,76.9749681 157.753257,83.5608592 C131.146257,89.8833146 107.963921,84.6146018 83.4644059,94.0982849 C27.6160498,115.436572 28.6697923,181.822354 59.2283268,196.838185 L59.2283268,196.838185 L61.0723763,197.891928 C61.0723763,197.891928 83.1456487,193.50309 104.973663,187.707242 L106.843514,187.207079 C115.561826,184.857554 124.138869,182.296538 131.146257,179.714869 C167.500376,166.279651 207.542593,133.08676 220.714375,94.6251562 C213.865049,134.667374 179.35498,173.392413 144.84491,191.042601 C126.404416,200.526284 112.178891,202.633769 81.883792,213.171195 C78.195693,214.488373 75.297901,215.805551 75.297901,215.805551 C75.6675607,215.754564 76.0372203,215.70481 76.4060145,215.65629 L77.1421925,215.560893 L77.1421925,215.560893 L77.8745239,215.468787 C84.5652297,214.639554 90.5771682,214.224938 90.5771682,214.224938 C133.517178,212.117452 200.956702,226.342977 232.305544,184.45671 C264.444692,141.780136 246.531068,72.7599979 231.778672,28.2393744 Z" fill="#6DB33F">
</path>
<path d="M57.9111486,207.375611 C62.3895545,211.06371 63.1798614,217.913037 59.4917624,222.654878 C55.8036635,227.39672 48.9543368,227.923591 44.2124952,224.235492 C39.4706537,220.547393 38.9437824,213.698066 42.6318814,208.956225 C46.3199803,204.214383 53.169307,203.687512 57.9111486,207.375611 Z M231.778672,28.2393744 C246.531068,72.7599979 264.444692,141.780136 232.305544,184.45671 C200.956702,226.342977 133.517178,212.117452 90.5771682,214.224938 C90.5771682,214.224938 84.5652297,214.639554 77.8745239,215.468787 L77.1421925,215.560893 C76.5300999,215.63902 75.9140004,215.720572 75.297901,215.805551 C75.297901,215.805551 78.195693,214.488373 81.883792,213.171195 C112.178891,202.633769 126.404416,200.526284 144.84491,191.042601 C179.35498,173.392413 213.865049,134.667374 220.714375,94.6251562 C207.542593,133.08676 167.500376,166.279651 131.146257,179.714869 C106.119871,188.935116 61.0723763,197.891928 61.0723763,197.891928 L59.2283268,196.838185 C28.6697923,181.822354 27.6160498,115.436572 83.4644059,94.0982849 C107.963921,84.6146018 131.146257,89.8833146 157.753257,83.5608592 C185.940871,76.9749681 218.60689,55.9001168 231.778672,28.2393744 Z" fill="#FFFFFF">
</path>

After

Width:  |  Height:  |  Size: 3.6 KiB

1
images/vscode.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="15" height="15" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden"><defs><clipPath id="clip0"><rect x="479" y="279" width="15" height="15"/></clipPath><clipPath id="clip1"><rect x="-0.287396" y="-0.171573" width="152381" height="152381"/></clipPath><image width="35" height="35" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAMAAAApB0NrAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAH4UExURQAAAASExCGs7CC//wB9vQB5uxSU1yaz8ySy9AB8uwB8vAB5uxOR1Say8yax8iaw8gB6vAB6uwB7uwB4uROQ1Cax8ySy8QCAvwB3ugB5ugB2uROP1CWw8yav8wCT2QCQ1QCP1wB2uQB1uBOO0yWv8yat8wCP1QCP1QCP1QCO1QCQ1wB1tQB2uAB3uAB1tx+k6SSt8ySt8QCN0wCO1ACM0wBzuQBztAB1twB0tgB0tiSr8SSs8gCM1ACM1ACEywBwtQBxtAB0tgBztQB0sySp8SSr8gCL0wCK0gCL0wCHzwByuABusgBztQBttiOq8gB6wQCI0QCJ0gB7wySn8SOo8gBxtABtsABwtgCFzwCI0gCG0QCJ0SKn8SKn8iKl8QBvsABvsgBvsQBtsABssgCCzACG0QCF0ACDzyKm8QBtrwBusABsrgCAzQCE0ACF0ACF0ACE0CKj8SKl8QBssQBtrwBtrwBsrgBsrwCK1QCBzwCD0ACA0B2d7CGj8QBsrABrrQBwrwCA3wCC0ACCzwCBzwB+zhGM3iGi8SKh8QCAzQCAzgCAzwB9zRCK3iCh8SGh8SCf8QB+zAB/zgB8zRCJ3SCg8SCf7wB+zQB6zBCI3CCe8CCf8CCe8CCf8SCf/wB7zAB4yxGJ3h6c8CCd7yCf7wmA0RqX60C//5CaUeMAAACodFJOUwA8XAho+//ncID/////53iM//////9wCKv/////gCiYILf///+AMPP/70wYw/+3lP+AXP+MKNv/+3uA/3z/+////+NAgP9c9/////+7HP+A///MgP9Y+////7scgP+AeP/////7/+NA/2D/iyTb//t4gP808//vUBjD/7eU/yifIAi3//////+Ar////////4CI/////3B//////+9/CGj7/+dwEDhYBCm1XqwAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAFpSURBVDhPY2AYBaiAkYkZXQgdsLCysXPgV8XJxc3Dy8vHj0eVgKCQsIioqKioGLoMDIhLSEpKSknLyMjIyKLLyckrgJUoCgsLCyspq6ioqKiiKVFT19DUYmDQ1tEFAT19AwMDA0M0NUbGxsbGJqZm5ubm5haWDFbW1tbWVmhqGGxsbW1t7ewdHB2dnBkYXFxdXV1d0NUwuLl7eHh4enn7+DIwMLj4+fn5Yaph8A8IDAwMDBIHsYNDQkJCgtFVMISGhUdERkZGRkUzMDDExMbGxsahK4lPSExKTklNTU1NS2dgiMvIyMhAV5OZlZWVlZ2Tm5eXl5dfwFBYVFRUVIimpriktKycgaGisgoEqmtqa2tr0dUw1NU3gKjGpuaWlpbWtvb29vYOdDUw0NjZ1dXd09vX39c3AV0OASZOmjR5ytSpU6dOQ5dBAtN7ZsycNXvO3HnoEshg/oKFixYvQRdFA0uXLUcXGqEAAH4FV0z+qQbjAAAAAElFTkSuQmCC" preserveAspectRatio="none" id="img2"></image><clipPath id="clip3"><path d="M44291.4 46947.4 187148 46947.4 187148 188823 44291.4 188823Z" fill-rule="evenodd" clip-rule="evenodd"/></clipPath></defs><g clip-path="url(#clip0)" transform="translate(-479 -279)"><g clip-path="url(#clip1)" transform="matrix(0.000105 0 0 0.000105 479 279)"><g clip-path="url(#clip3)" transform="matrix(1 0 0 1.00692 -44291.4 -47272.4)"><use width="100%" height="100%" xlink:href="#img2" transform="scale(6709.45 6709.45)"></use></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1914,13 +1914,20 @@ input::-webkit-calendar-picker-indicator::after {
}
.nav-tabs-margin {
height: 32px;
background-color: #f2f2f2;
.nav-tabs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
height: 100%;
margin-bottom: -0.5px;
li {
// Override the bootstrap defaults here to align with our layout constants.
margin-bottom: 0px;
height: 32px;
}
}
}

View File

@@ -211,3 +211,12 @@ a:focus {
.fileImportImg img {
filter: brightness(0) saturate(100%);
}
.tabPanesContainer {
overflow: auto !important;
}
.tabs-container {
min-height: 500px;
min-width: 500px;
}

File diff suppressed because it is too large Load Diff

66
package-lock.json generated
View File

@@ -51,6 +51,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -86,7 +88,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "4.6.2",
"p-retry": "6.2.1",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
@@ -12662,7 +12664,9 @@
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"license": "MIT"
},
"node_modules/@types/sanitize-html": {
@@ -13238,6 +13242,19 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"license": "BSD-3-Clause"
@@ -21799,6 +21816,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-network-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz",
"integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "3.0.0",
"license": "MIT",
@@ -30243,14 +30272,20 @@
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"@types/retry": "0.12.2",
"is-network-error": "^1.0.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
@@ -35997,6 +36032,13 @@
}
}
},
"node_modules/webpack-dev-server/node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack-dev-server/node_modules/ajv": {
"version": "8.12.0",
"dev": true,
@@ -36044,6 +36086,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-dev-server/node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/webpack-dev-server/node_modules/rimraf": {
"version": "3.0.2",
"dev": true,

View File

@@ -46,6 +46,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/xterm": "5.5.0",
"@xterm/addon-fit": "0.10.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -81,7 +83,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "4.6.2",
"p-retry": "6.2.1",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",

View File

@@ -1,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -29,24 +28,60 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
use: {
...devices["Desktop Firefox"],
launchOptions: {
firefoxUserPrefs: {
"security.fileuri.strict_origin_policy": false,
"network.http.referer.XOriginPolicy": 0,
"network.http.referer.trimmingPolicy": 0,
"privacy.file_unique_origin": false,
"security.csp.enable": false,
"network.cors_preflight.allow_client_cert": true,
"dom.security.https_first": false,
"network.http.cross-origin-embedder-policy": false,
"network.http.cross-origin-opener-policy": false,
"browser.tabs.remote.useCrossOriginPolicy": false,
"browser.tabs.remote.useCORP": false,
},
args: ["--disable-web-security"],
},
},
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
use: {
...devices["Desktop Safari"],
},
},
/* Test against branded browsers. */
{
name: "Google Chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" }, // or 'chrome-beta'
use: {
...devices["Desktop Chrome"],
channel: "chrome",
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
{
name: "Microsoft Edge",
use: { ...devices["Desktop Edge"], channel: "msedge" }, // or 'msedge-dev'
use: {
...devices["Desktop Edge"],
channel: "msedge",
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
],

37911
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -257,6 +257,7 @@ export class Areas {
public static ShareDialog: string = "Share Access Dialog";
public static Notebook: string = "Notebook";
public static Copilot: string = "Copilot";
public static CloudShell: string = "Cloud Shell";
}
export class HttpHeaders {
@@ -530,8 +531,8 @@ export class ariaLabelForLearnMoreLink {
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
}
export class MaterializedViewsLabels {
public static readonly NewMaterializedView: string = "New Materialized View";
export class GlobalSecondaryIndexLabels {
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
}
export class FeedbackLabels {
public static readonly provideFeedback: string = "Provide feedback";

View File

@@ -125,7 +125,11 @@ export const endpoint = () => {
const location = _global.parent ? _global.parent.location : _global.location;
return configContext.EMULATOR_ENDPOINT || location.origin;
}
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint;
return (
userContext.selectedRegionalEndpoint ||
userContext.endpoint ||
userContext?.databaseAccount?.properties?.documentEndpoint
);
};
export async function getTokenFromAuthService(
@@ -203,6 +207,7 @@ export function client(): Cosmos.CosmosClient {
userAgentSuffix: "Azure Portal",
defaultHeaders: _defaultHeaders,
connectionPolicy: {
enableEndpointDiscovery: !userContext.selectedRegionalEndpoint,
retryOptions: {
maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts),
fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval),

View File

@@ -1,5 +1,6 @@
import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() {
@@ -27,6 +28,8 @@ export function getWorkloadType(): WorkloadType {
return workloadType;
}
export function isMaterializedViewsEnabled(): boolean {
return userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews;
export function isGlobalSecondaryIndexEnabled(): boolean {
return (
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
);
}

View File

@@ -1,5 +1,8 @@
import { QueryOperationOptions } from "@azure/cosmos";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as Constants from "../Common/Constants";
import { QueryResults } from "../Contracts/ViewModels";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
interface QueryResponse {
// [Todo] remove any
@@ -21,7 +24,9 @@ export function nextPage(
firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> {
TelemetryProcessor.traceStart(Action.ExecuteQuery);
return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
const documents = response.resources;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any

View File

@@ -1,6 +1,6 @@
import { constructRpOptions } from "Common/dataAccess/createCollection";
import { handleError } from "Common/ErrorHandlingUtils";
import { Collection, CreateMaterializedViewsParams } from "Contracts/DataModels";
import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources";
import {
@@ -10,9 +10,9 @@ import {
} from "Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils";
export const createMaterializedView = async (params: CreateMaterializedViewsParams): Promise<Collection> => {
export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise<Collection> => {
const clearMessage = logConsoleProgress(
`Creating a new materialized view ${params.materializedViewId} for database ${params.databaseId}`,
`Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`,
);
const options: CreateUpdateOptions = constructRpOptions(params);
@@ -58,11 +58,15 @@ export const createMaterializedView = async (params: CreateMaterializedViewsPara
params.materializedViewId,
rpPayload,
);
logConsoleInfo(`Successfully created materialized view ${params.materializedViewId}`);
logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`);
return createResponse && (createResponse.properties.resource as Collection);
} catch (error) {
handleError(error, "CreateMaterializedView", `Error while creating materialized view ${params.materializedViewId}`);
handleError(
error,
"CreateGlobalSecondaryIndex",
`Error while creating global secondary index ${params.materializedViewId}`,
);
throw error;
} finally {
clearMessage();

View File

@@ -1,3 +1,4 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -13,6 +14,11 @@ import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
try {
if (
userContext.authType === AuthType.AAD &&

View File

@@ -187,7 +187,8 @@ if (process.env.NODE_ENV === "development") {
PROXY_PATH: "/proxy",
EMULATOR_ENDPOINT: "https://localhost:8081",
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
// MONGO_PROXY_ENDPOINT: "https://cosmos-db-portal-mongoproxy1-mpac-westus.azurewebsites.net",
MONGO_PROXY_ENDPOINT: "https://localhost:7238",
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Mpac,
});
}

View File

@@ -18,10 +18,13 @@ export type DataExploreMessageV3 =
| {
type: FabricMessageTypes.GetAllResourceTokens;
id: string;
}
| {
type: FabricMessageTypes.OpenSettings;
settingsId: string;
};
export type GetCosmosTokenMessageOptions = {
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges";
resourceId: string;
};
}

View File

@@ -210,7 +210,7 @@ export interface IndexingPolicy {
export interface VectorIndex {
path: string;
type: "flat" | "diskANN" | "quantizedFlat";
diskANNShardKey?: string;
vectorIndexShardKey?: string[];
indexingSearchListSize?: number;
quantizationByteSize?: number;
}

View File

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

View File

@@ -81,6 +81,13 @@ export type FabricMessageV3 =
error: string | undefined;
data: { accessToken: string };
};
}
| {
type: "refreshResourceTree";
message: {
id: string;
error: string | undefined;
};
};
export enum CosmosDbArtifactType {

View File

@@ -1,4 +1,5 @@
import {
JSONObject,
QueryMetrics,
Resource,
StoredProcedureDefinition,
@@ -206,6 +207,12 @@ export interface Collection extends CollectionBase {
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
bulkInsertDocuments(documents: JSONObject[]): Promise<{
numSucceeded: number;
numFailed: number;
numThrottled: number;
errors: string[];
}>;
}
/**

View File

@@ -1,11 +1,11 @@
import { MaterializedViewsLabels } from "Common/Constants";
import { isMaterializedViewsEnabled } from "Common/DatabaseAccountUtility";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext";
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import {
AddMaterializedViewPanel,
AddMaterializedViewPanelProps,
} from "Explorer/Panes/AddMaterializedViewPanel/AddMaterializedViewPanel";
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
@@ -103,17 +103,23 @@ export const createCollectionContextMenuButton = (
iconSrc: HostedTerminalIcon,
onClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
if (useNotebook.getState().isShellEnabled) {
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else {
selectedCollection && selectedCollection.onNewMongoShellClick();
}
},
label: useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell",
label:
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
? "Open Mongo Shell"
: "New Shell",
});
}
if (useNotebook.getState().isShellEnabled && userContext.apiType === "Cassandra") {
if (
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
userContext.apiType === "Cassandra"
) {
items.push({
iconSrc: HostedTerminalIcon,
onClick: () => {
@@ -170,19 +176,19 @@ export const createCollectionContextMenuButton = (
});
}
if (isMaterializedViewsEnabled() && !selectedCollection.materializedViewDefinition()) {
if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) {
items.push({
label: MaterializedViewsLabels.NewMaterializedView,
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
onClick: () => {
const addMaterializedViewPanelProps: AddMaterializedViewPanelProps = {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer: container,
sourceContainer: selectedCollection,
};
useSidePanel
.getState()
.openSidePanel(
MaterializedViewsLabels.NewMaterializedView,
<AddMaterializedViewPanel {...addMaterializedViewPanelProps} />,
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
);
},
});

View File

@@ -214,8 +214,10 @@ export const Dialog: FC = () => {
{contentHtml}
{progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />}
<DialogFooter>
<PrimaryButton {...primaryButtonProps} />
{secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />}
<PrimaryButton {...primaryButtonProps} data-test={`DialogButton:${primaryButtonText}`} />
{secondaryButtonProps && (
<DefaultButton {...secondaryButtonProps} data-test={`DialogButton:${secondaryButtonText}`} />
)}
</DialogFooter>
</FluentDialog>
) : (

View File

@@ -193,6 +193,7 @@ export const InputDataList: FC<InputDataListProps> = ({
<>
<Input
id="filterInput"
data-test={"DocumentsTab/FilterInput"}
ref={inputRef}
type="text"
size="small"

View File

@@ -12,6 +12,7 @@ import {
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
@@ -44,11 +45,11 @@ import {
ConflictResolutionComponent,
ConflictResolutionComponentProps,
} from "./SettingsSubComponents/ConflictResolutionComponent";
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
import {
MaterializedViewComponent,
MaterializedViewComponentProps,
} from "./SettingsSubComponents/MaterializedViewComponent";
GlobalSecondaryIndexComponent,
GlobalSecondaryIndexComponentProps,
} from "./SettingsSubComponents/GlobalSecondaryIndexComponent";
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
import {
MongoIndexingPolicyComponent,
MongoIndexingPolicyComponentProps,
@@ -166,7 +167,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private shouldShowComputedPropertiesEditor: boolean;
private shouldShowIndexingPolicyEditor: boolean;
private shouldShowPartitionKeyEditor: boolean;
private isMaterializedView: boolean;
private isGlobalSecondaryIndex: boolean;
private isVectorSearchEnabled: boolean;
private isFullTextSearchEnabled: boolean;
private totalThroughputUsed: number;
@@ -184,7 +185,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL";
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
this.isMaterializedView =
this.isGlobalSecondaryIndex =
!!this.collection?.materializedViewDefinition() || !!this.collection?.materializedViews();
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
@@ -1148,6 +1149,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
collection: this.collection,
database: this.database,
isFixedContainer: this.isFixedContainer,
isGlobalSecondaryIndex: this.isGlobalSecondaryIndex,
onThroughputChange: this.onThroughputChange,
throughput: this.state.throughput,
throughputBaseline: this.state.throughputBaseline,
@@ -1213,6 +1215,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
isFullTextSearchEnabled: this.isFullTextSearchEnabled,
shouldDiscardContainerPolicies: this.state.shouldDiscardContainerPolicies,
resetShouldDiscardContainerPolicyChange: this.resetShouldDiscardContainerPolicies,
isGlobalSecondaryIndex: this.isGlobalSecondaryIndex,
};
const indexingPolicyComponentProps: IndexingPolicyComponentProps = {
@@ -1277,9 +1280,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId),
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
isReadOnly: isFabricNative(),
};
const materializedViewComponentProps: MaterializedViewComponentProps = {
const globalSecondaryIndexComponentProps: GlobalSecondaryIndexComponentProps = {
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
};
@@ -1347,10 +1351,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
if (this.isMaterializedView) {
if (this.isGlobalSecondaryIndex) {
tabs.push({
tab: SettingsV2TabTypes.MaterializedViewTab,
content: <MaterializedViewComponent {...materializedViewComponentProps} />,
tab: SettingsV2TabTypes.GlobalSecondaryIndexTab,
content: <GlobalSecondaryIndexComponent {...globalSecondaryIndexComponentProps} />,
});
}

View File

@@ -22,6 +22,7 @@ export interface ContainerPolicyComponentProps {
isFullTextSearchEnabled: boolean;
shouldDiscardContainerPolicies: boolean;
resetShouldDiscardContainerPolicyChange: () => void;
isGlobalSecondaryIndex?: boolean;
}
export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> = ({

View File

@@ -0,0 +1,46 @@
import { shallow } from "enzyme";
import React from "react";
import { collection, container } from "../TestUtils";
import { GlobalSecondaryIndexComponent } from "./GlobalSecondaryIndexComponent";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
describe("GlobalSecondaryIndexComponent", () => {
let testCollection: typeof collection;
let testExplorer: typeof container;
beforeEach(() => {
testCollection = { ...collection };
});
it("renders only the source component when materializedViewDefinition is missing", () => {
testCollection.materializedViews([
{ id: "view1", _rid: "rid1" },
{ id: "view2", _rid: "rid2" },
]);
testCollection.materializedViewDefinition(null);
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(true);
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false);
});
it("renders only the target component when materializedViews is missing", () => {
testCollection.materializedViews(null);
testCollection.materializedViewDefinition({
definition: "SELECT * FROM c WHERE c.id = 1",
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
});
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false);
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(true);
});
it("renders neither component when both are missing", () => {
testCollection.materializedViews(null);
testCollection.materializedViewDefinition(null);
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false);
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false);
});
});

View File

@@ -2,22 +2,27 @@ import { FontIcon, Link, Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
import { MaterializedViewSourceComponent } from "./MaterializedViewSourceComponent";
import { MaterializedViewTargetComponent } from "./MaterializedViewTargetComponent";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
export interface MaterializedViewComponentProps {
export interface GlobalSecondaryIndexComponentProps {
collection: ViewModels.Collection;
explorer: Explorer;
}
export const MaterializedViewComponent: React.FC<MaterializedViewComponentProps> = ({ collection, explorer }) => {
export const GlobalSecondaryIndexComponent: React.FC<GlobalSecondaryIndexComponentProps> = ({
collection,
explorer,
}) => {
const isTargetContainer = !!collection?.materializedViewDefinition();
const isSourceContainer = !!collection?.materializedViews();
return (
<Stack tokens={{ childrenGap: 8 }} styles={{ root: { maxWidth: 600 } }}>
<Stack horizontal verticalAlign="center" wrap tokens={{ childrenGap: 8 }}>
<Text styles={{ root: { fontWeight: 600 } }}>This container has the following views defined for it.</Text>
{isSourceContainer && (
<Text styles={{ root: { fontWeight: 600 } }}>This container has the following indexes defined for it.</Text>
)}
<Text>
<Link
target="_blank"
@@ -26,11 +31,11 @@ export const MaterializedViewComponent: React.FC<MaterializedViewComponentProps>
Learn more
<FontIcon iconName="NavigateExternalInline" style={{ marginLeft: "4px" }} />
</Link>{" "}
about how to define materialized views and how to use them.
about how to define global secondary indexes and how to use them.
</Text>
</Stack>
{isSourceContainer && <MaterializedViewSourceComponent collection={collection} explorer={explorer} />}
{isTargetContainer && <MaterializedViewTargetComponent collection={collection} />}
{isSourceContainer && <GlobalSecondaryIndexSourceComponent collection={collection} explorer={explorer} />}
{isTargetContainer && <GlobalSecondaryIndexTargetComponent collection={collection} />}
</Stack>
);
};

View File

@@ -2,9 +2,9 @@ import { PrimaryButton } from "@fluentui/react";
import { shallow } from "enzyme";
import React from "react";
import { collection, container } from "../TestUtils";
import { MaterializedViewSourceComponent } from "./MaterializedViewSourceComponent";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
describe("MaterializedViewSourceComponent", () => {
describe("GlobalSecondaryIndexSourceComponent", () => {
let testCollection: typeof collection;
let testExplorer: typeof container;
@@ -13,17 +13,23 @@ describe("MaterializedViewSourceComponent", () => {
});
it("renders without crashing", () => {
const wrapper = shallow(<MaterializedViewSourceComponent collection={testCollection} explorer={testExplorer} />);
const wrapper = shallow(
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
);
expect(wrapper.exists()).toBe(true);
});
it("renders the PrimaryButton", () => {
const wrapper = shallow(<MaterializedViewSourceComponent collection={testCollection} explorer={testExplorer} />);
const wrapper = shallow(
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
);
expect(wrapper.find(PrimaryButton).exists()).toBe(true);
});
it("updates when new materialized views are provided", () => {
const wrapper = shallow(<MaterializedViewSourceComponent collection={testCollection} explorer={testExplorer} />);
it("updates when new global secondary indexes are provided", () => {
const wrapper = shallow(
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
);
// Simulating an update by modifying the observable directly
testCollection.materializedViews([{ id: "view3", _rid: "rid3" }]);

View File

@@ -1,29 +1,30 @@
import { PrimaryButton } from "@fluentui/react";
import { MaterializedViewsLabels } from "Common/Constants";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { MaterializedView } from "Contracts/DataModels";
import Explorer from "Explorer/Explorer";
import { loadMonaco } from "Explorer/LazyMonaco";
import { AddMaterializedViewPanel } from "Explorer/Panes/AddMaterializedViewPanel/AddMaterializedViewPanel";
import { AddGlobalSecondaryIndexPanel } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { useSidePanel } from "hooks/useSidePanel";
import * as monaco from "monaco-editor";
import React, { useEffect, useRef } from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
export interface MaterializedViewSourceComponentProps {
export interface GlobalSecondaryIndexSourceComponentProps {
collection: ViewModels.Collection;
explorer: Explorer;
}
export const MaterializedViewSourceComponent: React.FC<MaterializedViewSourceComponentProps> = ({
export const GlobalSecondaryIndexSourceComponent: React.FC<GlobalSecondaryIndexSourceComponentProps> = ({
collection,
explorer,
}) => {
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(null);
const materializedViews = collection?.materializedViews() ?? [];
const globalSecondaryIndexes: MaterializedView[] = collection?.materializedViews() ?? [];
// Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedViews[] with collection id.
// Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedView[] with collection id.
const getViewDetails = (viewId: string): { definition: string; partitionKey: string[] } => {
let definition = "";
let partitionKey: string[] = [];
@@ -31,8 +32,8 @@ export const MaterializedViewSourceComponent: React.FC<MaterializedViewSourceCom
useDatabases.getState().databases.find((database) => {
const collection = database.collections().find((collection) => collection.id() === viewId);
if (collection) {
const materializedViewDefinition = collection.materializedViewDefinition();
materializedViewDefinition && (definition = materializedViewDefinition.definition);
const globalSecondaryIndexDefinition = collection.materializedViewDefinition();
globalSecondaryIndexDefinition && (definition = globalSecondaryIndexDefinition.definition);
collection.partitionKey?.paths && (partitionKey = collection.partitionKey.paths);
}
});
@@ -42,7 +43,7 @@ export const MaterializedViewSourceComponent: React.FC<MaterializedViewSourceCom
//JSON value for the editor using the fetched id and definitions.
const jsonValue = JSON.stringify(
materializedViews.map((view) => {
globalSecondaryIndexes.map((view) => {
const { definition, partitionKey } = getViewDetails(view.id);
return {
name: view.id,
@@ -66,7 +67,7 @@ export const MaterializedViewSourceComponent: React.FC<MaterializedViewSourceCom
editorRef.current = monacoInstance.editor.create(editorContainerRef.current, {
value: jsonValue,
language: "json",
ariaLabel: "Materialized Views JSON",
ariaLabel: "Global Secondary Index JSON",
readOnly: true,
});
};
@@ -97,14 +98,14 @@ export const MaterializedViewSourceComponent: React.FC<MaterializedViewSourceCom
}}
/>
<PrimaryButton
text="Add view"
text="Add index"
styles={{ root: { width: "fit-content", marginTop: 12 } }}
onClick={() =>
useSidePanel
.getState()
.openSidePanel(
MaterializedViewsLabels.NewMaterializedView,
<AddMaterializedViewPanel explorer={explorer} sourceContainer={collection} />,
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel explorer={explorer} sourceContainer={collection} />,
)
}
/>

View File

@@ -3,9 +3,9 @@ import { Collection } from "Contracts/ViewModels";
import { shallow } from "enzyme";
import React from "react";
import { collection } from "../TestUtils";
import { MaterializedViewTargetComponent } from "./MaterializedViewTargetComponent";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
describe("MaterializedViewTargetComponent", () => {
describe("GlobalSecondaryIndexTargetComponent", () => {
let testCollection: Collection;
beforeEach(() => {
@@ -16,17 +16,17 @@ describe("MaterializedViewTargetComponent", () => {
});
it("renders without crashing", () => {
const wrapper = shallow(<MaterializedViewTargetComponent collection={testCollection} />);
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
expect(wrapper.exists()).toBe(true);
});
it("displays the source container ID", () => {
const wrapper = shallow(<MaterializedViewTargetComponent collection={testCollection} />);
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
expect(wrapper.find(Text).at(2).dive().text()).toBe("source1");
});
it("displays the materialized view definition", () => {
const wrapper = shallow(<MaterializedViewTargetComponent collection={testCollection} />);
it("displays the global secondary index definition", () => {
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
expect(wrapper.find(Text).at(4).dive().text()).toBe("SELECT * FROM c WHERE c.id = 1");
});
});

View File

@@ -2,12 +2,14 @@ import { Stack, Text } from "@fluentui/react";
import * as React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
export interface MaterializedViewTargetComponentProps {
export interface GlobalSecondaryIndexTargetComponentProps {
collection: ViewModels.Collection;
}
export const MaterializedViewTargetComponent: React.FC<MaterializedViewTargetComponentProps> = ({ collection }) => {
const materializedViewDefinition = collection?.materializedViewDefinition();
export const GlobalSecondaryIndexTargetComponent: React.FC<GlobalSecondaryIndexTargetComponentProps> = ({
collection,
}) => {
const globalSecondaryIndexDefinition = collection?.materializedViewDefinition();
const textHeadingStyle = {
root: { fontWeight: "600", fontSize: 16 },
@@ -23,19 +25,19 @@ export const MaterializedViewTargetComponent: React.FC<MaterializedViewTargetCom
return (
<Stack tokens={{ childrenGap: 15 }} styles={{ root: { maxWidth: 600 } }}>
<Text styles={textHeadingStyle}>Materialized View Settings</Text>
<Text styles={textHeadingStyle}>Global Secondary Index Settings</Text>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={{ root: { fontWeight: "600" } }}>Source container</Text>
<Stack styles={valueBoxStyle}>
<Text>{materializedViewDefinition?.sourceCollectionId}</Text>
<Text>{globalSecondaryIndexDefinition?.sourceCollectionId}</Text>
</Stack>
</Stack>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={{ root: { fontWeight: "600" } }}>Materialized view definition</Text>
<Text styles={{ root: { fontWeight: "600" } }}>Global secondary index definition</Text>
<Stack styles={valueBoxStyle}>
<Text>{materializedViewDefinition?.definition}</Text>
<Text>{globalSecondaryIndexDefinition?.definition}</Text>
</Stack>
</Stack>
</Stack>

View File

@@ -1,46 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { collection, container } from "../TestUtils";
import { MaterializedViewComponent } from "./MaterializedViewComponent";
import { MaterializedViewSourceComponent } from "./MaterializedViewSourceComponent";
import { MaterializedViewTargetComponent } from "./MaterializedViewTargetComponent";
describe("MaterializedViewComponent", () => {
let testCollection: typeof collection;
let testExplorer: typeof container;
beforeEach(() => {
testCollection = { ...collection };
});
it("renders only the source component when materializedViewDefinition is missing", () => {
testCollection.materializedViews([
{ id: "view1", _rid: "rid1" },
{ id: "view2", _rid: "rid2" },
]);
testCollection.materializedViewDefinition(null);
const wrapper = shallow(<MaterializedViewComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(MaterializedViewSourceComponent).exists()).toBe(true);
expect(wrapper.find(MaterializedViewTargetComponent).exists()).toBe(false);
});
it("renders only the target component when materializedViews is missing", () => {
testCollection.materializedViews(null);
testCollection.materializedViewDefinition({
definition: "SELECT * FROM c WHERE c.id = 1",
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
});
const wrapper = shallow(<MaterializedViewComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(MaterializedViewSourceComponent).exists()).toBe(false);
expect(wrapper.find(MaterializedViewTargetComponent).exists()).toBe(true);
});
it("renders neither component when both are missing", () => {
testCollection.materializedViews(null);
testCollection.materializedViewDefinition(null);
const wrapper = shallow(<MaterializedViewComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(MaterializedViewSourceComponent).exists()).toBe(false);
expect(wrapper.find(MaterializedViewTargetComponent).exists()).toBe(false);
});
});

View File

@@ -0,0 +1,41 @@
import { shallow } from "enzyme";
import {
PartitionKeyComponent,
PartitionKeyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import Explorer from "Explorer/Explorer";
import React from "react";
describe("PartitionKeyComponent", () => {
// Create a test setup function to get fresh instances for each test
const setupTest = () => {
// Create an instance of the mocked Explorer
const explorer = new Explorer();
// Create minimal mock objects for database and collection
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockCollection = {} as any as import("../../../../Contracts/ViewModels").Collection;
// Create props with the mocked Explorer instance
const props: PartitionKeyComponentProps = {
database: mockDatabase,
collection: mockCollection,
explorer,
};
return { explorer, props };
};
it("renders default component and matches snapshot", () => {
const { props } = setupTest();
const wrapper = shallow(<PartitionKeyComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders read-only component and matches snapshot", () => {
const { props } = setupTest();
const wrapper = shallow(<PartitionKeyComponent {...props} isReadOnly={true} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -29,16 +29,26 @@ export interface PartitionKeyComponentProps {
database: ViewModels.Database;
collection: ViewModels.Collection;
explorer: Explorer;
isReadOnly?: boolean; // true: cannot change partition key
}
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ database, collection, explorer }) => {
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
database,
collection,
explorer,
isReadOnly,
}) => {
const { dataTransferJobs } = useDataTransferJobs();
const [portalDataTransferJob, setPortalDataTransferJob] = React.useState<DataTransferJobGetResults>(null);
React.useEffect(() => {
if (isReadOnly) {
return;
}
const loadDataTransferJobs = refreshDataTransferOperations;
loadDataTransferJobs();
}, []);
}, [isReadOnly]);
React.useEffect(() => {
const currentJob = findPortalDataTransferJob();
@@ -151,7 +161,7 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ da
return (
<Stack tokens={{ childrenGap: 20 }} styles={{ root: { maxWidth: 600 } }}>
<Stack tokens={{ childrenGap: 10 }}>
<Text styles={textHeadingStyle}>Change {partitionKeyName.toLowerCase()}</Text>
{!isReadOnly && <Text styles={textHeadingStyle}>Change {partitionKeyName.toLowerCase()}</Text>}
<Stack horizontal tokens={{ childrenGap: 20 }}>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={textSubHeadingStyle}>Current {partitionKeyName.toLowerCase()}</Text>
@@ -163,56 +173,61 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ da
</Stack>
</Stack>
</Stack>
<MessageBar messageBarType={MessageBarType.warning}>
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the
source container for the entire duration of the partition key change process.
<Link
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
target="_blank"
underline
>
Learn more
</Link>
</MessageBar>
<Text>
To change the partition key, a new destination container must be created or an existing destination container
selected. Data will then be copied to the destination container.
</Text>
{configContext.platform !== Platform.Emulator && (
<PrimaryButton
styles={{ root: { width: "fit-content" } }}
text="Change"
onClick={startPartitionkeyChangeWorkflow}
disabled={isCurrentJobInProgress(portalDataTransferJob)}
/>
)}
{portalDataTransferJob && (
<Stack>
<Text styles={textHeadingStyle}>{partitionKeyName} change job</Text>
<Stack
horizontal
tokens={{ childrenGap: 20 }}
styles={{
root: {
alignItems: "center",
},
}}
>
<ProgressIndicator
label={portalDataTransferJob?.properties?.jobName}
description={getProgressDescription()}
percentComplete={getPercentageComplete()}
styles={{
root: {
width: "85%",
},
}}
></ProgressIndicator>
{isCurrentJobInProgress(portalDataTransferJob) && (
<DefaultButton text="Cancel" onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)} />
)}
</Stack>
</Stack>
{!isReadOnly && (
<>
<MessageBar messageBarType={MessageBarType.warning}>
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to
the source container for the entire duration of the partition key change process.
<Link
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
target="_blank"
underline
>
Learn more
</Link>
</MessageBar>
<Text>
To change the partition key, a new destination container must be created or an existing destination
container selected. Data will then be copied to the destination container.
</Text>
{configContext.platform !== Platform.Emulator && (
<PrimaryButton
styles={{ root: { width: "fit-content" } }}
text="Change"
onClick={startPartitionkeyChangeWorkflow}
disabled={isCurrentJobInProgress(portalDataTransferJob)}
/>
)}
{portalDataTransferJob && (
<Stack>
<Text styles={textHeadingStyle}>{partitionKeyName} change job</Text>
<Stack
horizontal
tokens={{ childrenGap: 20 }}
styles={{
root: {
alignItems: "center",
},
}}
>
<ProgressIndicator
label={portalDataTransferJob?.properties?.jobName}
description={getProgressDescription()}
percentComplete={getPercentageComplete()}
styles={{
root: {
width: "85%",
},
}}
></ProgressIndicator>
{isCurrentJobInProgress(portalDataTransferJob) && (
<DefaultButton text="Cancel" onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)} />
)}
</Stack>
</Stack>
)}
</>
)}
</Stack>
);

View File

@@ -9,6 +9,7 @@ describe("ScaleComponent", () => {
collection: collection,
database: undefined,
isFixedContainer: false,
isGlobalSecondaryIndex: false,
onThroughputChange: () => {
return;
},

View File

@@ -22,6 +22,7 @@ export interface ScaleComponentProps {
collection: ViewModels.Collection;
database: ViewModels.Database;
isFixedContainer: boolean;
isGlobalSecondaryIndex: boolean;
onThroughputChange: (newThroughput: number) => void;
throughput: number;
throughputBaseline: number;
@@ -143,6 +144,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
throughputError={this.props.throughputError}
instantMaximumThroughput={this.offer?.instantMaximumThroughput}
softAllowedMaximumThroughput={this.offer?.softAllowedMaximumThroughput}
isGlobalSecondaryIndex={this.props.isGlobalSecondaryIndex}
/>
);

View File

@@ -143,4 +143,39 @@ describe("SubSettingsComponent", () => {
expect(subSettingsComponentInstance.getTtlValue(TtlType.On)).toEqual(TtlOn);
expect(subSettingsComponentInstance.getTtlValue(TtlType.Off)).toEqual(TtlOff);
});
it("uniqueKey is visible", () => {
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableSQL" }],
},
} as DatabaseAccount,
});
const subSettingsComponent = new SubSettingsComponent(baseProps);
expect(subSettingsComponent.getUniqueKeyVisible()).toEqual(true);
});
it("uniqueKey not visible due to no keys", () => {
const props = {
...baseProps,
...(baseProps.collection.rawDataModel.uniqueKeyPolicy.uniqueKeys = []),
};
const subSettingsComponent = new SubSettingsComponent(props);
expect(subSettingsComponent.getUniqueKeyVisible()).toEqual(false);
});
it("uniqueKey not visible for API", () => {
const newContainer = new Explorer();
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableMongo" }],
},
} as DatabaseAccount,
});
const props = { ...baseProps, container: newContainer };
const subSettingsComponent = new SubSettingsComponent(props);
expect(subSettingsComponent.getUniqueKeyVisible()).toEqual(false);
});
});

View File

@@ -63,12 +63,16 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
private geospatialVisible: boolean;
private partitionKeyValue: string;
private partitionKeyName: string;
private uniqueKeyName: string;
private uniqueKeyValue: string;
constructor(props: SubSettingsComponentProps) {
super(props);
this.geospatialVisible = userContext.apiType === "SQL";
this.partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
this.partitionKeyValue = this.getPartitionKeyValue();
this.uniqueKeyName = "Unique keys";
this.uniqueKeyValue = this.getUniqueKeyValue();
}
componentDidMount(): void {
@@ -351,6 +355,28 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
public isLargePartitionKeyEnabled = (): boolean => this.props.collection.partitionKey?.version >= 2;
public isHierarchicalPartitionedContainer = (): boolean => this.props.collection.partitionKey?.kind === "MultiHash";
public getUniqueKeyVisible = (): boolean => {
return this.props.collection.rawDataModel.uniqueKeyPolicy?.uniqueKeys.length > 0 && userContext.apiType === "SQL";
};
private getUniqueKeyValue = (): string => {
const paths = this.props.collection.rawDataModel.uniqueKeyPolicy?.uniqueKeys?.[0]?.paths;
return paths?.join(", ") || "";
};
private getUniqueKeyComponent = (): JSX.Element => (
<Stack {...titleAndInputStackProps}>
{this.getUniqueKeyVisible() && (
<TextField
label={this.uniqueKeyName}
disabled
styles={getTextFieldStyles(undefined, undefined)}
defaultValue={this.uniqueKeyValue}
/>
)}
</Stack>
);
public render(): JSX.Element {
return (
<Stack {...subComponentStackProps}>
@@ -363,6 +389,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
{this.props.changeFeedPolicyVisible && this.getChangeFeedComponent()}
{this.getPartitionKeyComponent()}
{this.getUniqueKeyComponent()}
</Stack>
);
}

View File

@@ -44,6 +44,7 @@ describe("ThroughputInputAutoPilotV3Component", () => {
},
instantMaximumThroughput: 5000,
softAllowedMaximumThroughput: 1000000,
isGlobalSecondaryIndex: false,
};
it("throughput input visible", () => {

View File

@@ -80,6 +80,7 @@ export interface ThroughputInputAutoPilotV3Props {
throughputError?: string;
instantMaximumThroughput: number;
softAllowedMaximumThroughput: number;
isGlobalSecondaryIndex: boolean;
}
interface ThroughputInputAutoPilotV3State {
@@ -375,22 +376,26 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
toolTipElement={getToolTipContainer(this.props.infoBubbleText)}
/>
</Label>
{this.overrideWithProvisionedThroughputSettings() && (
<MessageBar
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
styles={messageBarStyles}
>
{manualToAutoscaleDisclaimerElement}
</MessageBar>
{!this.props.isGlobalSecondaryIndex && (
<>
{this.overrideWithProvisionedThroughputSettings() && (
<MessageBar
messageBarIconProps={{ iconName: "InfoSolid", className: "messageBarInfoIcon" }}
styles={messageBarStyles}
>
{manualToAutoscaleDisclaimerElement}
</MessageBar>
)}
<ChoiceGroup
selectedKey={this.props.isAutoPilotSelected.toString()}
options={this.options}
onChange={this.onChoiceGroupChange}
required={this.props.showAsMandatory}
ariaLabelledBy={labelId}
styles={getChoiceGroupStyles(this.props.wasAutopilotOriginallySet, this.props.isAutoPilotSelected, true)}
/>
</>
)}
<ChoiceGroup
selectedKey={this.props.isAutoPilotSelected.toString()}
options={this.options}
onChange={this.onChoiceGroupChange}
required={this.props.showAsMandatory}
ariaLabelledBy={labelId}
styles={getChoiceGroupStyles(this.props.wasAutopilotOriginallySet, this.props.isAutoPilotSelected, true)}
/>
</Stack>
);
};

View File

@@ -0,0 +1,196 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = `
<Stack
styles={
{
"root": {
"maxWidth": 600,
},
}
}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 10,
}
}
>
<Text
styles={
{
"root": {
"fontSize": 16,
"fontWeight": 600,
},
}
}
>
Change
partition key
</Text>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<Text
styles={
{
"root": {
"fontWeight": 600,
},
}
}
>
Current
partition key
</Text>
<Text
styles={
{
"root": {
"fontWeight": 600,
},
}
}
>
Partitioning
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<Text />
<Text>
Non-hierarchical
</Text>
</Stack>
</Stack>
</Stack>
<StyledMessageBar
messageBarType={5}
>
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process.
<StyledLinkBase
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
target="_blank"
underline={true}
>
Learn more
</StyledLinkBase>
</StyledMessageBar>
<Text>
To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container.
</Text>
<CustomizedPrimaryButton
onClick={[Function]}
styles={
{
"root": {
"width": "fit-content",
},
}
}
text="Change"
/>
</Stack>
`;
exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = `
<Stack
styles={
{
"root": {
"maxWidth": 600,
},
}
}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 10,
}
}
>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<Text
styles={
{
"root": {
"fontWeight": 600,
},
}
}
>
Current
partition key
</Text>
<Text
styles={
{
"root": {
"fontWeight": 600,
},
}
}
>
Partitioning
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<Text />
<Text>
Non-hierarchical
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
`;

View File

@@ -231,6 +231,34 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
Non-hierarchically partitioned container.
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<StyledTextFieldBase
defaultValue="/id"
disabled={true}
label="Unique keys"
styles={
{
"fieldGroup": {
"borderColor": "",
"height": 25,
"selectors": {
":disabled": {
"backgroundColor": undefined,
"borderColor": undefined,
},
},
"width": 300,
},
}
}
/>
</Stack>
</Stack>
`;
@@ -520,6 +548,34 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
Non-hierarchically partitioned container.
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<StyledTextFieldBase
defaultValue="/id"
disabled={true}
label="Unique keys"
styles={
{
"fieldGroup": {
"borderColor": "",
"height": 25,
"selectors": {
":disabled": {
"backgroundColor": undefined,
"borderColor": undefined,
},
},
"width": 300,
},
}
}
/>
</Stack>
</Stack>
`;
@@ -769,6 +825,34 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
Non-hierarchically partitioned container.
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<StyledTextFieldBase
defaultValue="/id"
disabled={true}
label="Unique keys"
styles={
{
"fieldGroup": {
"borderColor": "",
"height": 25,
"selectors": {
":disabled": {
"backgroundColor": undefined,
"borderColor": undefined,
},
},
"width": 300,
},
}
}
/>
</Stack>
</Stack>
`;
@@ -1083,6 +1167,34 @@ exports[`SubSettingsComponent renders 1`] = `
Non-hierarchically partitioned container.
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<StyledTextFieldBase
defaultValue="/id"
disabled={true}
label="Unique keys"
styles={
{
"fieldGroup": {
"borderColor": "",
"height": 25,
"selectors": {
":disabled": {
"backgroundColor": undefined,
"borderColor": undefined,
},
},
"width": 300,
},
}
}
/>
</Stack>
</Stack>
`;
@@ -1371,5 +1483,33 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
Non-hierarchically partitioned container.
</Text>
</Stack>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<StyledTextFieldBase
defaultValue="/id"
disabled={true}
label="Unique keys"
styles={
{
"fieldGroup": {
"borderColor": "",
"height": 25,
"selectors": {
":disabled": {
"backgroundColor": undefined,
"borderColor": undefined,
},
},
"width": 300,
},
}
}
/>
</Stack>
</Stack>
`;

View File

@@ -1,6 +1,7 @@
import * as Constants from "../../../Common/Constants";
import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import { isFabricNative } from "../../../Platform/Fabric/FabricUtil";
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
const zeroValue = 0;
@@ -57,7 +58,7 @@ export enum SettingsV2TabTypes {
ComputedPropertiesTab,
ContainerVectorPolicyTab,
ThroughputBucketsTab,
MaterializedViewTab,
GlobalSecondaryIndexTab,
}
export enum ContainerPolicyTabTypes {
@@ -165,15 +166,15 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
case SettingsV2TabTypes.IndexingPolicyTab:
return "Indexing Policy";
case SettingsV2TabTypes.PartitionKeyTab:
return "Partition Keys (preview)";
return isFabricNative() ? "Partition Keys" : "Partition Keys (preview)";
case SettingsV2TabTypes.ComputedPropertiesTab:
return "Computed Properties";
case SettingsV2TabTypes.ContainerVectorPolicyTab:
return "Container Policies";
case SettingsV2TabTypes.ThroughputBucketsTab:
return "Throughput Buckets";
case SettingsV2TabTypes.MaterializedViewTab:
return "Materialized Views (Preview)";
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
return "Global Secondary Index (Preview)";
default:
throw new Error(`Unknown tab ${tab}`);
}

View File

@@ -17,7 +17,15 @@ export const collection = {
includedPaths: [],
excludedPaths: [],
}),
uniqueKeyPolicy: {} as DataModels.UniqueKeyPolicy,
rawDataModel: {
uniqueKeyPolicy: {
uniqueKeys: [
{
paths: ["/id"],
},
],
},
},
usageSizeInKB: ko.observable(100),
offer: ko.observable<DataModels.Offer>({
autoscaleMaxThroughput: undefined,

View File

@@ -71,14 +71,25 @@ exports[`SettingsComponent renders 1`] = `
"partitionKeyProperties": [
"partitionKey",
],
"rawDataModel": {
"uniqueKeyPolicy": {
"uniqueKeys": [
{
"paths": [
"/id",
],
},
],
},
},
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
isAutoPilotSelected={false}
isFixedContainer={false}
isGlobalSecondaryIndex={true}
onAutoPilotSelected={[Function]}
onMaxAutoPilotThroughputChange={[Function]}
onScaleDiscardableChange={[Function]}
@@ -152,8 +163,18 @@ exports[`SettingsComponent renders 1`] = `
"partitionKeyProperties": [
"partitionKey",
],
"rawDataModel": {
"uniqueKeyPolicy": {
"uniqueKeys": [
{
"paths": [
"/id",
],
},
],
},
},
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
@@ -273,8 +294,18 @@ exports[`SettingsComponent renders 1`] = `
"partitionKeyProperties": [
"partitionKey",
],
"rawDataModel": {
"uniqueKeyPolicy": {
"uniqueKeys": [
{
"paths": [
"/id",
],
},
],
},
},
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
@@ -306,6 +337,7 @@ exports[`SettingsComponent renders 1`] = `
},
}
}
isReadOnly={false}
/>
</PivotItem>
<PivotItem
@@ -343,16 +375,16 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerText="Materialized Views (Preview)"
itemKey="MaterializedViewTab"
key="MaterializedViewTab"
headerText="Global Secondary Index (Preview)"
itemKey="GlobalSecondaryIndexTab"
key="GlobalSecondaryIndexTab"
style={
{
"marginTop": 20,
}
}
>
<MaterializedViewComponent
<GlobalSecondaryIndexComponent
collection={
{
"analyticalStorageTtl": [Function],
@@ -402,8 +434,18 @@ exports[`SettingsComponent renders 1`] = `
"partitionKeyProperties": [
"partitionKey",
],
"rawDataModel": {
"uniqueKeyPolicy": {
"uniqueKeys": [
{
"paths": [
"/id",
],
},
],
},
},
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}

View File

@@ -18,6 +18,7 @@ export interface ThroughputInputProps {
isFreeTier: boolean;
showFreeTierExceedThroughputTooltip: boolean;
isQuickstart?: boolean;
isGlobalSecondaryIndex?: boolean;
setThroughputValue: (throughput: number) => void;
setIsAutoscale: (isAutoscale: boolean) => void;
setIsThroughputCapExceeded: (isThroughputCapExceeded: boolean) => void;
@@ -30,6 +31,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
isFreeTier,
showFreeTierExceedThroughputTooltip,
isQuickstart,
isGlobalSecondaryIndex,
setThroughputValue,
setIsAutoscale,
setIsThroughputCapExceeded,
@@ -193,41 +195,41 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
</Text>
<InfoTooltip>{PricingUtils.getRuToolTipText()}</InfoTooltip>
</Stack>
{!isGlobalSecondaryIndex && (
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
id="Autoscale-input"
className="throughputInputRadioBtn"
aria-label={`${getThroughputLabelText()} Autoscale`}
aria-required={true}
checked={isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Autoscale")}
/>
<label htmlFor="Autoscale-input" className="throughputInputRadioBtnLabel">
Autoscale
</label>
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
id="Autoscale-input"
className="throughputInputRadioBtn"
aria-label={`${getThroughputLabelText()} Autoscale`}
aria-required={true}
checked={isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Autoscale")}
/>
<label htmlFor="Autoscale-input" className="throughputInputRadioBtnLabel">
Autoscale
</label>
<input
id="Manual-input"
className="throughputInputRadioBtn"
aria-label={`${getThroughputLabelText()} Manual`}
checked={!isAutoscaleSelected}
type="radio"
aria-required={true}
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Manual")}
/>
<label className="throughputInputRadioBtnLabel" htmlFor="Manual-input">
Manual
</label>
</div>
</Stack>
<input
id="Manual-input"
className="throughputInputRadioBtn"
aria-label={`${getThroughputLabelText()} Manual`}
checked={!isAutoscaleSelected}
type="radio"
aria-required={true}
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Manual")}
/>
<label className="throughputInputRadioBtnLabel" htmlFor="Manual-input">
Manual
</label>
</div>
</Stack>
)}
{isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small" aria-label="capacity calculator of azure cosmos db">

View File

@@ -9,6 +9,7 @@ import {
Stack,
TextField,
} from "@fluentui/react";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import {
@@ -29,6 +30,7 @@ export interface IVectorEmbeddingPoliciesComponentProps {
discardChanges?: boolean;
onChangesDiscarded?: () => void;
disabled?: boolean;
isGlobalSecondaryIndex?: boolean;
}
export interface VectorEmbeddingPolicyData {
@@ -39,8 +41,7 @@ export interface VectorEmbeddingPolicyData {
indexType: VectorIndex["type"] | "none";
pathError: string;
dimensionsError: string;
diskANNShardKey?: string;
diskANNShardKeyError?: string;
vectorIndexShardKey?: string[];
indexingSearchListSize?: number;
indexingSearchListSizeError?: string;
quantizationByteSize?: number;
@@ -87,6 +88,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
discardChanges,
onChangesDiscarded,
disabled,
isGlobalSecondaryIndex,
}): JSX.Element => {
const onVectorEmbeddingPathError = (path: string, index?: number): string => {
let error = "";
@@ -132,12 +134,6 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
return error;
};
//TODO: no restrictions yet due to this field being removed for now.
// Uncomment and replace with validation code when field is reinstated
// const onDiskANNShardKeyError = (shardKey: string): string => {
// return "";
// };
const initializeData = (vectorEmbeddings: VectorEmbedding[], vectorIndexes: VectorIndex[]) => {
const mergedData: VectorEmbeddingPolicyData[] = [];
vectorEmbeddings.forEach((embedding) => {
@@ -147,6 +143,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
indexType: matchingIndex?.type || "none",
indexingSearchListSize: matchingIndex?.indexingSearchListSize || undefined,
quantizationByteSize: matchingIndex?.quantizationByteSize || undefined,
vectorIndexShardKey: matchingIndex?.vectorIndexShardKey || undefined,
pathError: onVectorEmbeddingPathError(embedding.path),
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
});
@@ -186,6 +183,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
type: policy.indexType,
indexingSearchListSize: policy.indexingSearchListSize,
quantizationByteSize: policy.quantizationByteSize,
vectorIndexShardKey: policy.vectorIndexShardKey,
}) as VectorIndex,
);
const validationPassed = vectorEmbeddingPolicyData.every(
@@ -247,20 +245,16 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
// TODO: uncomment after Ignite
// DiskANNShardKey was removed for Ignite due to backend problems. Leaving this here as it will be reinstated immediately after Ignite
// const onDiskANNShardKeyChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
// const value = event.target.value.trim();
// const vectorEmbeddings = [...vectorEmbeddingPolicyData];
// if (!vectorEmbeddings[index]?.diskANNShardKey && !value.startsWith("/")) {
// vectorEmbeddings[index].diskANNShardKey = "/" + value;
// } else {
// vectorEmbeddings[index].diskANNShardKey = value;
// }
// const error = onDiskANNShardKeyError(value);
// vectorEmbeddings[index].diskANNShardKeyError = error;
// setVectorEmbeddingPolicyData(vectorEmbeddings);
// }
const onShardKeyChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
if (!vectorEmbeddings[index]?.vectorIndexShardKey?.[0] && !value.startsWith("/")) {
vectorEmbeddings[index].vectorIndexShardKey = ["/" + value];
} else {
vectorEmbeddings[index].vectorIndexShardKey = [value];
}
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingPolicyChange = (
index: number,
@@ -292,6 +286,11 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const getQuantizationByteSizeTooltipContent = (): string => {
const containerName: string = isGlobalSecondaryIndex ? "global secondary index" : "container";
return `This is dynamically set by the ${containerName} if left blank, or it can be set to a fixed number`;
};
return (
<Stack tokens={{ childrenGap: 4 }}>
{vectorEmbeddingPolicyData &&
@@ -402,6 +401,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
styles={labelStyles}
>
Quantization byte size
<InfoTooltip>{getQuantizationByteSizeTooltipContent()}</InfoTooltip>
</Label>
<TextField
disabled={
@@ -431,26 +431,18 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
}
/>
</Stack>
{/*TODO: uncomment after Ignite */}
{/* DiskANNShardKey was removed for Ignite due to backend problems. Leaving this here as it will be reinstated immediately after Ignite
<Stack
style={{ marginLeft: "10px" }}
>
<Label
disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"}
styles={labelStyles}
>DiskANN shard key</Label>
<Stack style={{ marginLeft: "10px" }}>
<Label disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"} styles={labelStyles}>
Vector index shard key
</Label>
<TextField
disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"}
id={`vector-policy-diskANNShardKey-${index + 1}`}
id={`vector-policy-vectorIndexShardKey-${index + 1}`}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.diskANNShardKey || "")}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onDiskANNShardKeyChange(index, event)
}
value={String(vectorEmbeddingPolicy.vectorIndexShardKey?.[0] ?? "")}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onShardKeyChange(index, event)}
/>
</Stack>
*/}
</Stack>
)}
</Stack>

View File

@@ -6,6 +6,7 @@ import Explorer from "../Explorer";
import { useDatabases } from "../useDatabases";
import { ContainerSampleGenerator } from "./ContainerSampleGenerator";
// TODO: this does not seem to be used. Remove?
export class DataSamplesUtil {
private static readonly DialogTitle = "Create Sample Container";
constructor(private container: Explorer) {}

View File

@@ -282,6 +282,69 @@ export default class Explorer {
}
}
public openInVsCode(): void {
TelemetryProcessor.traceStart(Action.OpenVSCode);
this.openVsCodeButtonClick();
}
private openVsCodeButtonClick(): void {
const activeTab = useTabs.getState().activeTab;
const resourceId = encodeURIComponent(userContext.databaseAccount.id);
const database = encodeURIComponent(activeTab?.collection?.databaseId);
const container = encodeURIComponent(activeTab?.collection?.id());
const baseUrl = `vscod://ms-azuretools.vscode-cosmosdb?resourceId=${resourceId}`;
const vscodeUrl = activeTab ? `${baseUrl}&database=${database}&container=${container}` : baseUrl;
const startTime = Date.now();
let vsCodeNotOpened = false;
setTimeout(() => {
const timeOutTime = Date.now() - startTime;
if (!vsCodeNotOpened && timeOutTime < 1050) {
vsCodeNotOpened = true;
useDialog.getState().openDialog(openVSCodeDialogProps);
}
}, 1000);
const link = document.createElement("a");
link.href = vscodeUrl;
link.rel = "noopener noreferrer";
document.body.appendChild(link);
try {
link.click();
document.body.removeChild(link);
TelemetryProcessor.traceStart(Action.OpenVSCode);
} catch (error) {
if (!vsCodeNotOpened) {
vsCodeNotOpened = true;
logConsoleError(`Failed to open VS Code: ${getErrorMessage(error)}`);
}
}
const openVSCodeDialogProps: DialogProps = {
linkProps: {
linkText: "Download Visual Studio Code",
linkUrl: "https://code.visualstudio.com/download",
},
isModal: true,
title: `Open your Azure Cosmos DB account in Visual Studio Code`,
subText: `Please ensure Visual Studio Code is installed on your device.
If you don't have it installed, please download it from the link below.`,
primaryButtonText: "Open in VS Code",
secondaryButtonText: "Cancel",
onPrimaryButtonClick: () => {
vsCodeNotOpened = false;
this.openVsCodeButtonClick();
useDialog.getState().closeDialog();
},
onSecondaryButtonClick: () => {
useDialog.getState().closeDialog();
TelemetryProcessor.traceCancel(Action.OpenVSCode);
},
};
}
public async openCESCVAFeedbackBlade(): Promise<void> {
sendMessage({ type: MessageTypes.OpenCESCVAFeedbackBlade });
Logger.logInfo(
@@ -910,7 +973,9 @@ export default class Explorer {
}
public async openNotebookTerminal(kind: ViewModels.TerminalKind): Promise<void> {
if (useNotebook.getState().isPhoenixFeatures) {
if (userContext.features.enableCloudShell) {
this.connectToNotebookTerminal(kind);
} else if (useNotebook.getState().isPhoenixFeatures) {
await this.allocateContainer(PoolIdType.DefaultPoolId);
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) {

View File

@@ -14,6 +14,7 @@ import OpenQueryFromDiskIcon from "../../../../images/OpenQueryFromDisk.svg";
import OpenInTabIcon from "../../../../images/open-in-tab.svg";
import SettingsIcon from "../../../../images/settings_15x15.svg";
import SynapseIcon from "../../../../images/synapse-link.svg";
import VSCodeIcon from "../../../../images/vscode.svg";
import { AuthType } from "../../../AuthType";
import * as Constants from "../../../Common/Constants";
import { Platform, configContext } from "../../../ConfigContext";
@@ -60,6 +61,10 @@ export function createStaticCommandBarButtons(
addDivider();
buttons.push(addSynapseLink);
}
if (userContext.apiType !== "Gremlin") {
const addVsCode = createOpenVsCodeDialogButton(container);
buttons.push(addVsCode);
}
}
if (isDataplaneRbacSupported(userContext.apiType)) {
@@ -126,13 +131,14 @@ export function createContextCommandBarButtons(
const buttons: CommandButtonComponentProps[] = [];
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
const label = useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell";
const label =
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
const newMongoShellBtn: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
if (useNotebook.getState().isShellEnabled) {
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else {
selectedCollection && selectedCollection.onNewMongoShellClick();
@@ -146,7 +152,7 @@ export function createContextCommandBarButtons(
}
if (
useNotebook.getState().isShellEnabled &&
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
!selectedNodeState.isDatabaseNodeOrNoneSelected() &&
userContext.apiType === "Cassandra"
) {
@@ -267,6 +273,18 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
};
}
function createOpenVsCodeDialogButton(container: Explorer): CommandButtonComponentProps {
const label = "Visual Studio Code";
return {
iconSrc: VSCodeIcon,
iconAlt: label,
onCommandClick: () => container.openInVsCode(),
commandButtonLabel: label,
hasPopup: false,
ariaLabel: label,
};
}
function createLoginForEntraIDButton(container: Explorer): CommandButtonComponentProps {
if (configContext.platform !== Platform.Portal) {
return undefined;
@@ -455,7 +473,7 @@ function createOpenTerminalButtonByKind(
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
if (useNotebook.getState().isNotebookEnabled) {
if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) {
container.openNotebookTerminal(terminalKind);
}
},
@@ -499,6 +517,6 @@ export function createPostgreButtons(container: Explorer): CommandButtonComponen
export function createVCoreMongoButtons(container: Explorer): CommandButtonComponentProps[] {
const openVCoreMongoTerminalButton = createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.VCoreMongo);
return [openVCoreMongoTerminalButton];
const addVsCode = createOpenVsCodeDialogButton(container);
return [openVCoreMongoTerminalButton, addVsCode];
}

View File

@@ -2,7 +2,7 @@
* Notebook container related stuff
*/
import { useDialog } from "Explorer/Controls/Dialog";
import promiseRetry, { AbortError } from "p-retry";
import promiseRetry, { AbortError, Options } from "p-retry";
import { PhoenixClient } from "Phoenix/PhoenixClient";
import * as Constants from "../../Common/Constants";
import { ConnectionStatusType, HttpHeaders, HttpStatusCodes, Notebook, PoolIdType } from "../../Common/Constants";
@@ -19,7 +19,7 @@ export class NotebookContainerClient {
private clearReconnectionAttemptMessage? = () => {};
private isResettingWorkspace: boolean;
private phoenixClient: PhoenixClient;
private retryOptions: promiseRetry.Options;
private retryOptions: Options;
private scheduleTimerId: NodeJS.Timeout;
constructor(private onConnectionLost: () => void) {

View File

@@ -56,9 +56,9 @@ import {
isVectorSearchEnabled,
} from "Utils/CapabilityUtils";
import { getUpsellMessage } from "Utils/PricingUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import { CollapsibleSectionComponent } from "../../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
import "../../Controls/ThroughputInput/ThroughputInput.less";
import { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator";
import Explorer from "../../Explorer";
import { useDatabases } from "../../useDatabases";
@@ -331,8 +331,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
required
type="text"
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder="Type a new database id"
size={40}
className="panelTextField"
@@ -439,8 +439,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
aria-required
required
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"

View File

@@ -93,7 +93,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
id="newDatabaseId"
name="newDatabaseId"
onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]"
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="Type a new database id"
required={true}
size={40}
@@ -178,7 +178,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
id="collectionId"
name="collectionId"
onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]"
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="e.g., Container1"
required={true}
size={40}

View File

@@ -1,5 +1,6 @@
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as Constants from "../../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
@@ -204,8 +205,8 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
type="text"
aria-required="true"
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
size={40}
aria-label={databaseIdLabel}
placeholder={databaseIdPlaceHolder}

View File

@@ -39,7 +39,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
data-lpignore={true}
id="database-id"
onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]"
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="Type a new database id"
size={40}
styles={

View File

@@ -0,0 +1,28 @@
import { shallow, ShallowWrapper } from "enzyme";
import Explorer from "Explorer/Explorer";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import React, { Component } from "react";
const props: AddGlobalSecondaryIndexPanelProps = {
explorer: new Explorer(),
};
describe("AddGlobalSecondaryIndexPanel", () => {
it("render default panel", () => {
const wrapper: ShallowWrapper<AddGlobalSecondaryIndexPanelProps, object, Component> = shallow(
<AddGlobalSecondaryIndexPanel {...props} />,
);
expect(wrapper).toMatchSnapshot();
});
it("should render form", () => {
const wrapper: ShallowWrapper<AddGlobalSecondaryIndexPanelProps, object, Component> = shallow(
<AddGlobalSecondaryIndexPanel {...props} />,
);
const form = wrapper.find("form").first();
expect(form).toBeDefined();
});
});

View File

@@ -11,7 +11,7 @@ import {
TooltipHost,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { createMaterializedView } from "Common/dataAccess/createMaterializedView";
import { createGlobalSecondaryIndex } from "Common/dataAccess/createMaterializedView";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import * as DataModels from "Contracts/DataModels";
import { FullTextIndex, FullTextPolicy, VectorEmbedding, VectorIndex } from "Contracts/DataModels";
@@ -22,21 +22,19 @@ import {
FullTextPolicyDefault,
getPartitionKey,
isSynapseLinkEnabled,
parseUniqueKeys,
scrollToSection,
shouldShowAnalyticalStoreOptions,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import {
chooseSourceContainerStyle,
chooseSourceContainerStyles,
} from "Explorer/Panes/AddMaterializedViewPanel/AddMaterializedViewPanelStyles";
import { AddMVAdvancedComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVAdvancedComponent";
import { AddMVAnalyticalStoreComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVAnalyticalStoreComponent";
import { AddMVFullTextSearchComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVFullTextSearchComponent";
import { AddMVPartitionKeyComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVPartitionKeyComponent";
import { AddMVThroughputComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVThroughputComponent";
import { AddMVUniqueKeysComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVUniqueKeysComponent";
import { AddMVVectorSearchComponent } from "Explorer/Panes/AddMaterializedViewPanel/AddMVVectorSearchComponent";
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles";
import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent";
import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent";
import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent";
import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent";
import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent";
import { VectorSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent";
import { PanelFooterComponent } from "Explorer/Panes/PanelFooterComponent";
import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent";
import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen";
@@ -48,31 +46,31 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { isFullTextSearchEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
export interface AddMaterializedViewPanelProps {
export interface AddGlobalSecondaryIndexPanelProps {
explorer: Explorer;
sourceContainer?: Collection;
}
export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps): JSX.Element => {
export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanelProps): JSX.Element => {
const { explorer, sourceContainer } = props;
const [sourceContainerOptions, setSourceContainerOptions] = useState<IDropdownOption[]>();
const [selectedSourceContainer, setSelectedSourceContainer] = useState<Collection>(sourceContainer);
const [materializedViewId, setMaterializedViewId] = useState<string>();
const [globalSecondaryIndexId, setGlobalSecondaryIndexId] = useState<string>();
const [definition, setDefinition] = useState<string>();
const [partitionKey, setPartitionKey] = useState<string>(getPartitionKey());
const [subPartitionKeys, setSubPartitionKeys] = useState<string[]>([]);
const [useHashV1, setUseHashV1] = useState<boolean>();
const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState<boolean>();
const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState<boolean>();
const [uniqueKeys, setUniqueKeys] = useState<string[]>([]);
const [enableAnalyticalStore, setEnableAnalyticalStore] = useState<boolean>();
const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState<VectorEmbedding[]>();
const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState<VectorIndex[]>();
const [vectorPolicyValidated, setVectorPolicyValidated] = useState<boolean>();
const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState<VectorEmbedding[]>([]);
const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState<VectorIndex[]>([]);
const [vectorPolicyValidated, setVectorPolicyValidated] = useState<boolean>(true);
const [fullTextPolicy, setFullTextPolicy] = useState<FullTextPolicy>(FullTextPolicyDefault);
const [fullTextIndexes, setFullTextIndexes] = useState<FullTextIndex[]>();
const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState<boolean>();
const [fullTextIndexes, setFullTextIndexes] = useState<FullTextIndex[]>([]);
const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string>();
const [showErrorDetails, setShowErrorDetails] = useState<boolean>();
const [isExecuting, setIsExecuting] = useState<boolean>();
@@ -87,13 +85,13 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
});
database.collections().forEach((collection: Collection) => {
const isMaterializedView: boolean = !!collection.materializedViewDefinition();
const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition();
sourceContainerOptions.push({
key: collection.rid,
text: collection.id(),
disabled: isMaterializedView,
...(isMaterializedView && {
title: "This is a materialized view.",
disabled: isGlobalSecondaryIndex,
...(isGlobalSecondaryIndex && {
title: "This is a global secondary index.",
}),
data: collection,
});
@@ -107,16 +105,11 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
scrollToSection("panelContainer");
}, [errorMessage]);
let materializedViewThroughput: number;
let isMaterializedViewAutoscale: boolean;
let globalSecondaryIndexThroughput: number;
let isCostAcknowledged: boolean;
const materializedViewThroughputOnChange = (materializedViewThroughputValue: number): void => {
materializedViewThroughput = materializedViewThroughputValue;
};
const isMaterializedViewAutoscaleOnChange = (isMaterializedViewAutoscaleValue: boolean): void => {
isMaterializedViewAutoscale = isMaterializedViewAutoscaleValue;
const globalSecondaryIndexThroughputOnChange = (globalSecondaryIndexThroughputValue: number): void => {
globalSecondaryIndexThroughput = globalSecondaryIndexThroughputValue;
};
const isCostAknowledgedOnChange = (isCostAcknowledgedValue: boolean): void => {
@@ -176,19 +169,12 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
return false;
}
if (materializedViewThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
const errorMessage = isMaterializedViewAutoscale
? "Please acknowledge the estimated monthly spend."
: "Please acknowledge the estimated daily spend.";
if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
const errorMessage: string = "Please acknowledge the estimated monthly spend.";
setErrorMessage(errorMessage);
return false;
}
if (materializedViewThroughput > CollectionCreation.MaxRUPerPartition) {
setErrorMessage("Unsharded collections support up to 10,000 RUs");
return false;
}
if (showVectorSearchParameters()) {
if (!vectorPolicyValidated) {
setErrorMessage("Please fix errors in container vector policy");
@@ -211,16 +197,15 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
return;
}
const materializedViewIdTrimmed: string = materializedViewId.trim();
const globalSecondaryIdTrimmed: string = globalSecondaryIndexId.trim();
const materializedViewDefinition: DataModels.MaterializedViewDefinition = {
sourceCollectionId: sourceContainer.id(),
const globalSecondaryIndexDefinition: DataModels.MaterializedViewDefinition = {
sourceCollectionId: selectedSourceContainer.id(),
definition: definition,
};
const partitionKeyTrimmed: string = partitionKey.trim();
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(uniqueKeys);
const partitionKeyVersion = useHashV1 ? undefined : 2;
const partitionKeyPaths: DataModels.PartitionKey = partitionKeyTrimmed
? {
@@ -253,11 +238,10 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
shared: isSelectedSourceContainerSharedThroughput(),
},
collection: {
id: materializedViewIdTrimmed,
throughput: materializedViewThroughput,
isAutoscale: isMaterializedViewAutoscale,
id: globalSecondaryIdTrimmed,
throughput: globalSecondaryIndexThroughput,
isAutoscale: true,
partitionKeyPaths,
uniqueKeyPolicy,
collectionWithDedicatedThroughput: enableDedicatedThroughput,
},
subscriptionQuotaId: userContext.quotaId,
@@ -267,28 +251,17 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData);
const databaseLevelThroughput: boolean = isSelectedSourceContainerSharedThroughput() && !enableDedicatedThroughput;
let offerThroughput: number;
let autoPilotMaxThroughput: number;
if (!databaseLevelThroughput) {
if (isMaterializedViewAutoscale) {
autoPilotMaxThroughput = materializedViewThroughput;
} else {
offerThroughput = materializedViewThroughput;
}
}
const createMaterializedViewParams: DataModels.CreateMaterializedViewsParams = {
materializedViewId: materializedViewIdTrimmed,
materializedViewDefinition: materializedViewDefinition,
const createGlobalSecondaryIndexParams: DataModels.CreateMaterializedViewsParams = {
materializedViewId: globalSecondaryIdTrimmed,
materializedViewDefinition: globalSecondaryIndexDefinition,
databaseId: selectedSourceContainer.databaseId,
databaseLevelThroughput: databaseLevelThroughput,
offerThroughput: offerThroughput,
autoPilotMaxThroughput: autoPilotMaxThroughput,
...(!databaseLevelThroughput && {
autoPilotMaxThroughput: globalSecondaryIndexThroughput,
}),
analyticalStorageTtl: getAnalyticalStorageTtl(),
indexingPolicy: indexingPolicy,
partitionKey: partitionKeyPaths,
uniqueKeyPolicy: uniqueKeyPolicy,
vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal,
fullTextPolicy: fullTextPolicy,
};
@@ -296,23 +269,23 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
setIsExecuting(true);
try {
await createMaterializedView(createMaterializedViewParams);
await createGlobalSecondaryIndex(createGlobalSecondaryIndexParams);
await explorer.refreshAllDatabases();
TelemetryProcessor.traceSuccess(Action.CreateMaterializedView, telemetryData, startKey);
TelemetryProcessor.traceSuccess(Action.CreateGlobalSecondaryIndex, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
} catch (error) {
const errorMessage: string = getErrorMessage(error);
setErrorMessage(errorMessage);
setShowErrorDetails(true);
const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) };
TelemetryProcessor.traceFailure(Action.CreateMaterializedView, failureTelemetryData, startKey);
TelemetryProcessor.traceFailure(Action.CreateGlobalSecondaryIndex, failureTelemetryData, startKey);
} finally {
setIsExecuting(false);
}
};
return (
<form className="panelFormWrapper" id="panelMaterializedView" onSubmit={submit}>
<form className="panelFormWrapper" id="panelGlobalSecondaryIndex" onSubmit={submit}>
{errorMessage && (
<PanelInfoErrorComponent message={errorMessage} messageType="error" showErrorDetails={showErrorDetails} />
)}
@@ -327,7 +300,7 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
<Dropdown
placeholder="Choose source container"
options={sourceContainerOptions}
defaultSelectedKey={sourceContainer?.rid}
defaultSelectedKey={selectedSourceContainer?.rid}
styles={chooseSourceContainerStyles()}
style={chooseSourceContainerStyle()}
onChange={(_, options: IDropdownOption) => setSelectedSourceContainer(options.data as Collection)}
@@ -336,27 +309,27 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
View container id
Global secondary index container id
</Text>
</Stack>
<input
id="materializedViewId"
id="globalSecondaryIndexId"
type="text"
aria-required
required
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder={`e.g., viewByEmailId`}
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={`e.g., indexbyEmailId`}
size={40}
className="panelTextField"
value={materializedViewId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMaterializedViewId(event.target.value)}
value={globalSecondaryIndexId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGlobalSecondaryIndexId(event.target.value)}
/>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Materialized View Definition
Global secondary index definition
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
@@ -365,7 +338,7 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
Learn more about defining materialized views.
Learn more about defining global secondary indexes.
</Link>
}
>
@@ -373,7 +346,7 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
</TooltipHost>
</Stack>
<input
id="materializedViewDefinition"
id="globalSecondaryIndexDefinition"
type="text"
aria-required
required
@@ -384,27 +357,25 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
value={definition || ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDefinition(event.target.value)}
/>
<AddMVPartitionKeyComponent
<PartitionKeyComponent
{...{ partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 }}
/>
<AddMVThroughputComponent
<ThroughputComponent
{...{
enableDedicatedThroughput,
setEnabledDedicatedThroughput,
isSelectedSourceContainerSharedThroughput,
showCollectionThroughputInput,
materializedViewThroughputOnChange,
isMaterializedViewAutoscaleOnChange,
globalSecondaryIndexThroughputOnChange,
setIsThroughputCapExceeded,
isCostAknowledgedOnChange,
}}
/>
<AddMVUniqueKeysComponent {...{ uniqueKeys, setUniqueKeys }} />
{shouldShowAnalyticalStoreOptions() && (
<AddMVAnalyticalStoreComponent {...{ explorer, enableAnalyticalStore, setEnableAnalyticalStore }} />
<AnalyticalStoreComponent {...{ explorer, enableAnalyticalStore, setEnableAnalyticalStore }} />
)}
{showVectorSearchParameters() && (
<AddMVVectorSearchComponent
<VectorSearchComponent
{...{
vectorEmbeddingPolicy,
setVectorEmbeddingPolicy,
@@ -412,15 +383,16 @@ export const AddMaterializedViewPanel = (props: AddMaterializedViewPanelProps):
setVectorIndexingPolicy,
vectorPolicyValidated,
setVectorPolicyValidated,
isGlobalSecondaryIndex: true,
}}
/>
)}
{showFullTextSearchParameters() && (
<AddMVFullTextSearchComponent
<FullTextSearchComponent
{...{ fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated }}
/>
)}
<AddMVAdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
<AdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
</Stack>
</div>
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={isThroughputCapExceeded} />

View File

@@ -5,12 +5,12 @@ import React from "react";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
export interface AddMVAdvancedComponentProps {
export interface AdvancedComponentProps {
useHashV1: boolean;
setUseHashV1: React.Dispatch<React.SetStateAction<boolean>>;
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
}
export const AddMVAdvancedComponent = (props: AddMVAdvancedComponentProps): JSX.Element => {
export const AdvancedComponent = (props: AdvancedComponentProps): JSX.Element => {
const { useHashV1, setUseHashV1, setSubPartitionKeys } = props;
const useHashV1CheckboxOnChange = (isChecked: boolean): void => {
@@ -23,7 +23,7 @@ export const AddMVAdvancedComponent = (props: AddMVAdvancedComponentProps): JSX.
title="Advanced"
isExpandedByDefault={false}
onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddMaterializedViewPaneAdvancedSection);
TelemetryProcessor.traceOpen(Action.ExpandAddGlobalSecondaryIndexPaneAdvancedSection);
scrollToSection("collapsibleAdvancedSectionContent");
}}
>

View File

@@ -8,12 +8,12 @@ import {
import React from "react";
import { getCollectionName } from "Utils/APITypeUtils";
export interface AddMVAnalyticalStoreComponentProps {
export interface AnalyticalStoreComponentProps {
explorer: Explorer;
enableAnalyticalStore: boolean;
setEnableAnalyticalStore: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AddMVAnalyticalStoreComponent = (props: AddMVAnalyticalStoreComponentProps): JSX.Element => {
export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => {
const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props;
const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => {

View File

@@ -5,13 +5,13 @@ import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullT
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface AddMVFullTextSearchComponentProps {
export interface FullTextSearchComponentProps {
fullTextPolicy: FullTextPolicy;
setFullTextPolicy: React.Dispatch<React.SetStateAction<FullTextPolicy>>;
setFullTextIndexes: React.Dispatch<React.SetStateAction<FullTextIndex[]>>;
setFullTextPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AddMVFullTextSearchComponent = (props: AddMVFullTextSearchComponentProps): JSX.Element => {
export const FullTextSearchComponent = (props: FullTextSearchComponentProps): JSX.Element => {
const { fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated } = props;
return (

View File

@@ -7,7 +7,7 @@ import {
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface AddMVPartitionKeyComponentProps {
export interface PartitionKeyComponentProps {
partitionKey?: string;
setPartitionKey: React.Dispatch<React.SetStateAction<string>>;
subPartitionKeys: string[];
@@ -15,7 +15,7 @@ export interface AddMVPartitionKeyComponentProps {
useHashV1: boolean;
}
export const AddMVPartitionKeyComponent = (props: AddMVPartitionKeyComponentProps): JSX.Element => {
export const PartitionKeyComponent = (props: PartitionKeyComponentProps): JSX.Element => {
const { partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 } = props;
const partitionKeyValueOnChange = (value: string): void => {
@@ -50,7 +50,7 @@ export const AddMVPartitionKeyComponent = (props: AddMVPartitionKeyComponentProp
<input
type="text"
id="addmaterializedView-partitionKeyValue"
id="addGlobalSecondaryIndex-partitionKeyValue"
aria-required
required
size={40}
@@ -77,8 +77,8 @@ export const AddMVPartitionKeyComponent = (props: AddMVPartitionKeyComponentProp
></div>
<input
type="text"
id="addMaterializedView-partitionKeyValue"
key={`addMaterializedView-partitionKeyValue_${subPartitionKeyIndex}`}
id="addGlobalSecondaryIndex-partitionKeyValue"
key={`addGlobalSecondaryIndex-partitionKeyValue_${subPartitionKeyIndex}`}
aria-required
required
size={40}

View File

@@ -6,25 +6,23 @@ import React from "react";
import { getCollectionName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils";
export interface AddMVThroughputComponentProps {
export interface ThroughputComponentProps {
enableDedicatedThroughput: boolean;
setEnabledDedicatedThroughput: React.Dispatch<React.SetStateAction<boolean>>;
isSelectedSourceContainerSharedThroughput: () => boolean;
showCollectionThroughputInput: () => boolean;
materializedViewThroughputOnChange: (materializedViewThroughputValue: number) => void;
isMaterializedViewAutoscaleOnChange: (isMaterializedViewAutoscaleValue: boolean) => void;
globalSecondaryIndexThroughputOnChange: (globalSecondaryIndexThroughputValue: number) => void;
setIsThroughputCapExceeded: React.Dispatch<React.SetStateAction<boolean>>;
isCostAknowledgedOnChange: (isCostAknowledgedValue: boolean) => void;
}
export const AddMVThroughputComponent = (props: AddMVThroughputComponentProps): JSX.Element => {
export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Element => {
const {
enableDedicatedThroughput,
setEnabledDedicatedThroughput,
isSelectedSourceContainerSharedThroughput,
showCollectionThroughputInput,
materializedViewThroughputOnChange,
isMaterializedViewAutoscaleOnChange,
globalSecondaryIndexThroughputOnChange,
setIsThroughputCapExceeded,
isCostAknowledgedOnChange,
} = props;
@@ -49,15 +47,14 @@ export const AddMVThroughputComponent = (props: AddMVThroughputComponentProps):
<ThroughputInput
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !useDatabases.getState().isFirstResourceCreated()}
isDatabase={false}
isSharded={false}
isSharded={true}
isFreeTier={isFreeTierAccount()}
isQuickstart={false}
isGlobalSecondaryIndex={true}
setThroughputValue={(throughput: number) => {
materializedViewThroughputOnChange(throughput);
}}
setIsAutoscale={(isAutoscale: boolean) => {
isMaterializedViewAutoscaleOnChange(isAutoscale);
globalSecondaryIndexThroughputOnChange(throughput);
}}
setIsAutoscale={() => {}}
setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => {
setIsThroughputCapExceeded(isThroughputCapExceeded);
}}

View File

@@ -8,21 +8,23 @@ import {
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface AddMVVectorSearchComponentProps {
export interface VectorSearchComponentProps {
vectorEmbeddingPolicy: VectorEmbedding[];
setVectorEmbeddingPolicy: React.Dispatch<React.SetStateAction<VectorEmbedding[]>>;
vectorIndexingPolicy: VectorIndex[];
setVectorIndexingPolicy: React.Dispatch<React.SetStateAction<VectorIndex[]>>;
setVectorPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
isGlobalSecondaryIndex?: boolean;
}
export const AddMVVectorSearchComponent = (props: AddMVVectorSearchComponentProps): JSX.Element => {
export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => {
const {
vectorEmbeddingPolicy,
setVectorEmbeddingPolicy,
vectorIndexingPolicy,
setVectorIndexingPolicy,
setVectorPolicyValidated,
isGlobalSecondaryIndex,
} = props;
return (
@@ -49,6 +51,7 @@ export const AddMVVectorSearchComponent = (props: AddMVVectorSearchComponentProp
setVectorIndexingPolicy(vectorIndexingPolicy);
setVectorPolicyValidated(vectorPolicyValidated);
}}
isGlobalSecondaryIndex={isGlobalSecondaryIndex}
/>
</Stack>
</Stack>

View File

@@ -1,9 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddMaterializedViewPanel render default panel 1`] = `
exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
<form
className="panelFormWrapper"
id="panelMaterializedView"
id="panelGlobalSecondaryIndex"
onSubmit={[Function]}
>
<div
@@ -67,17 +67,17 @@ exports[`AddMaterializedViewPanel render default panel 1`] = `
className="panelTextBold"
variant="small"
>
View container id
Global secondary index container id
</Text>
</Stack>
<input
aria-required={true}
autoComplete="off"
className="panelTextField"
id="materializedViewId"
id="globalSecondaryIndexId"
onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]"
placeholder="e.g., viewByEmailId"
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="e.g., indexbyEmailId"
required={true}
size={40}
title="May not end with space nor contain characters '\\' '/' '#' '?'"
@@ -95,7 +95,7 @@ exports[`AddMaterializedViewPanel render default panel 1`] = `
className="panelTextBold"
variant="small"
>
Materialized View Definition
Global secondary index definition
</Text>
<StyledTooltipHostBase
content={
@@ -103,7 +103,7 @@ exports[`AddMaterializedViewPanel render default panel 1`] = `
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
Learn more about defining materialized views.
Learn more about defining global secondary indexes.
</StyledLinkBase>
}
directionalHint={4}
@@ -120,7 +120,7 @@ exports[`AddMaterializedViewPanel render default panel 1`] = `
aria-required={true}
autoComplete="off"
className="panelTextField"
id="materializedViewDefinition"
id="globalSecondaryIndexDefinition"
onChange={[Function]}
placeholder="SELECT c.email, c.accountId FROM c"
required={true}
@@ -128,26 +128,21 @@ exports[`AddMaterializedViewPanel render default panel 1`] = `
type="text"
value=""
/>
<AddMVPartitionKeyComponent
<PartitionKeyComponent
partitionKey=""
setPartitionKey={[Function]}
setSubPartitionKeys={[Function]}
subPartitionKeys={[]}
/>
<AddMVThroughputComponent
<ThroughputComponent
globalSecondaryIndexThroughputOnChange={[Function]}
isCostAknowledgedOnChange={[Function]}
isMaterializedViewAutoscaleOnChange={[Function]}
isSelectedSourceContainerSharedThroughput={[Function]}
materializedViewThroughputOnChange={[Function]}
setEnabledDedicatedThroughput={[Function]}
setIsThroughputCapExceeded={[Function]}
showCollectionThroughputInput={[Function]}
/>
<AddMVUniqueKeysComponent
setUniqueKeys={[Function]}
uniqueKeys={[]}
/>
<AddMVAnalyticalStoreComponent
<AnalyticalStoreComponent
explorer={
Explorer {
"_isInitializingNotebooks": false,
@@ -177,7 +172,7 @@ exports[`AddMaterializedViewPanel render default panel 1`] = `
}
setEnableAnalyticalStore={[Function]}
/>
<AddMVAdvancedComponent
<AdvancedComponent
setSubPartitionKeys={[Function]}
setUseHashV1={[Function]}
/>

View File

@@ -1,78 +0,0 @@
import { ActionButton, IconButton, Stack } from "@fluentui/react";
import { UniqueKeysHeader } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
import { userContext } from "UserContext";
export interface AddMVUniqueKeysComponentProps {
uniqueKeys: string[];
setUniqueKeys: React.Dispatch<React.SetStateAction<string[]>>;
}
export const AddMVUniqueKeysComponent = (props: AddMVUniqueKeysComponentProps): JSX.Element => {
const { uniqueKeys, setUniqueKeys } = props;
const updateUniqueKeysOnChange = (value: string, uniqueKeyToReplaceIndex: number): void => {
const updatedUniqueKeys = uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number) => {
if (uniqueKeyToReplaceIndex === uniqueKeyIndex) {
return value;
}
return uniqueKey;
});
setUniqueKeys(updatedUniqueKeys);
};
const deleteUniqueKeyOnClick = (uniqueKeyToDeleteIndex: number): void => {
const updatedUniqueKeys = uniqueKeys.filter((_, uniqueKeyIndex) => uniqueKeyToDeleteIndex !== uniqueKeyIndex);
setUniqueKeys(updatedUniqueKeys);
};
const addUniqueKeyOnClick = (): void => {
setUniqueKeys([...uniqueKeys, ""]);
};
return (
<Stack>
{UniqueKeysHeader()}
{uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number): JSX.Element => {
return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey-${uniqueKeyIndex}`} horizontal>
<input
type="text"
autoComplete="off"
placeholder={
userContext.apiType === "Mongo"
? "Comma separated paths e.g. firstName,address.zipCode"
: "Comma separated paths e.g. /firstName,/address/zipCode"
}
className="panelTextField"
autoFocus
value={uniqueKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
updateUniqueKeysOnChange(event.target.value, uniqueKeyIndex);
}}
/>
<IconButton
iconProps={{ iconName: "Delete" }}
style={{ height: 27 }}
onClick={() => {
deleteUniqueKeyOnClick(uniqueKeyIndex);
}}
/>
</Stack>
);
})}
<ActionButton
iconProps={{ iconName: "Add" }}
styles={{ root: { padding: 0 }, label: { fontSize: 12 } }}
onClick={() => {
addUniqueKeyOnClick();
}}
>
Add unique key
</ActionButton>
</Stack>
);
};

View File

@@ -1,28 +0,0 @@
import { shallow, ShallowWrapper } from "enzyme";
import Explorer from "Explorer/Explorer";
import {
AddMaterializedViewPanel,
AddMaterializedViewPanelProps,
} from "Explorer/Panes/AddMaterializedViewPanel/AddMaterializedViewPanel";
import React, { Component } from "react";
const props: AddMaterializedViewPanelProps = {
explorer: new Explorer(),
};
describe("AddMaterializedViewPanel", () => {
it("render default panel", () => {
const wrapper: ShallowWrapper<AddMaterializedViewPanelProps, object, Component> = shallow(
<AddMaterializedViewPanel {...props} />,
);
expect(wrapper).toMatchSnapshot();
});
it("should render form", () => {
const wrapper: ShallowWrapper<AddMaterializedViewPanelProps, object, Component> = shallow(
<AddMaterializedViewPanel {...props} />,
);
const form = wrapper.find("form").first();
expect(form).toBeDefined();
});
});

View File

@@ -7,6 +7,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { isServerlessAccount } from "Utils/CapabilityUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react";
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
@@ -202,8 +203,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true}
autoComplete="off"
styles={getTextFieldStyles()}
pattern="[^/?#\\-]*[^/?#- \\]"
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder="Type a new keyspace id"
size={40}
value={newKeyspaceId}
@@ -292,8 +293,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true}
ariaLabel="addCollection-table Id Create table"
autoComplete="off"
pattern="[^/?#\\-]*[^/?#- \\]"
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder="Enter table Id"
size={20}
value={tableId}

View File

@@ -28,6 +28,7 @@ import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm";
import { useDatabases } from "Explorer/useDatabases";
import { userContext } from "UserContext";
import { getCollectionName } from "Utils/APITypeUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import { useSidePanel } from "hooks/useSidePanel";
import * as React from "react";
@@ -235,8 +236,8 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
aria-required
required
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ exports[`Settings Pane should render Default properly 1`] = `
>
<Accordion
className="customAccordion ___1uf6361_0000000 fz7g6wx"
collapsible={true}
>
<AccordionItem
value="1"
@@ -107,7 +108,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="3"
value="4"
>
<AccordionHeader>
<div
@@ -148,7 +149,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="4"
value="5"
>
<AccordionHeader>
<div
@@ -219,7 +220,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="5"
value="6"
>
<AccordionHeader>
<div
@@ -281,7 +282,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="6"
value="7"
>
<AccordionHeader>
<div
@@ -423,7 +424,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="7"
value="8"
>
<AccordionHeader>
<div
@@ -459,7 +460,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="8"
value="9"
>
<AccordionHeader>
<div
@@ -495,7 +496,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="9"
value="10"
>
<AccordionHeader>
<div
@@ -573,9 +574,10 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
>
<Accordion
className="customAccordion ___1uf6361_0000000 fz7g6wx"
collapsible={true}
>
<AccordionItem
value="6"
value="7"
>
<AccordionHeader>
<div
@@ -717,7 +719,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="7"
value="8"
>
<AccordionHeader>
<div
@@ -753,7 +755,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="11"
value="12"
>
<AccordionHeader>
<div

View File

@@ -356,7 +356,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
value=""
>
<div
className="ms-TextField is-required root-110"
className="ms-TextField is-required root-116"
>
<div
className="ms-TextField-wrapper"
@@ -647,7 +647,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
}
>
<label
className="ms-Label root-121"
className="ms-Label root-127"
htmlFor="TextField0"
id="TextFieldLabel2"
>
@@ -656,13 +656,13 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
</LabelBase>
</StyledLabelBase>
<div
className="ms-TextField-fieldGroup fieldGroup-111"
className="ms-TextField-fieldGroup fieldGroup-117"
>
<input
aria-invalid={false}
aria-labelledby="TextFieldLabel2"
autoFocus={true}
className="ms-TextField-field field-112"
className="ms-TextField-field field-118"
id="TextField0"
name="collectionIdConfirmation"
onBlur={[Function]}
@@ -2464,7 +2464,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<button
aria-label="Create"
className="ms-Button ms-Button--primary root-122"
className="ms-Button ms-Button--primary root-128"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
@@ -2477,14 +2477,14 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
type="submit"
>
<span
className="ms-Button-flexContainer flexContainer-123"
className="ms-Button-flexContainer flexContainer-129"
data-automationid="splitbuttonprimary"
>
<span
className="ms-Button-textContainer textContainer-124"
className="ms-Button-textContainer textContainer-130"
>
<span
className="ms-Button-label label-126"
className="ms-Button-label label-132"
id="id__5"
key="id__5"
>

View File

@@ -2,9 +2,13 @@ import {
DetailsList,
DetailsListLayoutMode,
DirectionalHint,
FontIcon,
IColumn,
SelectionMode,
TooltipHost,
getTheme,
mergeStyles,
mergeStyleSets,
} from "@fluentui/react";
import { Upload } from "Common/Upload/Upload";
import { UploadDetailsRecord } from "Contracts/ViewModels";
@@ -14,6 +18,36 @@ import { getErrorMessage } from "../../Tables/Utilities";
import { useSelectedNode } from "../../useSelectedNode";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
const theme = getTheme();
const iconClass = mergeStyles({
verticalAlign: "middle",
maxHeight: "16px",
maxWidth: "16px",
});
const classNames = mergeStyleSets({
fileIconHeaderIcon: {
padding: 0,
fontSize: "16px",
},
fileIconCell: {
textAlign: "center",
selectors: {
"&:before": {
content: ".",
display: "inline-block",
verticalAlign: "middle",
height: "100%",
width: "0px",
visibility: "hidden",
},
},
},
error: [{ color: theme.semanticColors.errorIcon }, iconClass],
accept: [{ color: theme.semanticColors.successIcon }, iconClass],
warning: [{ color: theme.semanticColors.warningIcon }, iconClass],
});
export const UploadItemsPane: FunctionComponent = () => {
const [files, setFiles] = useState<FileList>();
const [uploadFileData, setUploadFileData] = useState<UploadDetailsRecord[]>([]);
@@ -60,44 +94,94 @@ export const UploadItemsPane: FunctionComponent = () => {
};
const columns: IColumn[] = [
{
key: "icons",
name: "",
fieldName: "",
className: classNames.fileIconCell,
iconClassName: classNames.fileIconHeaderIcon,
isIconOnly: true,
minWidth: 16,
maxWidth: 16,
onRender: (item: UploadDetailsRecord, index: number, column: IColumn) => {
if (item.numFailed) {
const errorList = (
<ul
aria-label={"error list"}
style={{
margin: "5px 0",
paddingLeft: "20px",
listStyleType: "disc", // Explicitly set to use bullets (dots)
}}
>
{item.errors.map((error, i) => (
<li key={i} style={{ display: "list-item" }}>
{error}
</li>
))}
</ul>
);
return (
<TooltipHost
content={errorList}
id={`tooltip-${index}-${column.key}`}
directionalHint={DirectionalHint.bottomAutoEdge}
>
<FontIcon iconName="Error" className={classNames.error} aria-label="error" />
</TooltipHost>
);
} else if (item.numThrottled) {
return <FontIcon iconName="Warning" className={classNames.warning} aria-label="warning" />;
} else {
return <FontIcon iconName="Accept" className={classNames.accept} aria-label="accept" />;
}
},
},
{
key: "fileName",
name: "FILE NAME",
fieldName: "fileName",
minWidth: 140,
minWidth: 120,
maxWidth: 140,
onRender: (item: UploadDetailsRecord, index: number, column: IColumn) => {
const fieldContent = item.fileName;
return (
<TooltipHost
content={fieldContent}
id={`tooltip-${index}-${column.key}`}
directionalHint={DirectionalHint.bottomAutoEdge}
>
{fieldContent}
</TooltipHost>
);
},
},
{
key: "status",
name: "STATUS",
fieldName: "numSucceeded",
minWidth: 140,
minWidth: 120,
maxWidth: 140,
isRowHeader: true,
isResizable: true,
data: "string",
isPadded: true,
onRender: (item: UploadDetailsRecord, index: number, column: IColumn) => {
const fieldContent = `${item.numSucceeded} created, ${item.numThrottled} throttled, ${item.numFailed} errors`;
return (
<TooltipHost
content={fieldContent}
id={`tooltip-${index}-${column.key}`}
directionalHint={DirectionalHint.bottomAutoEdge}
>
{fieldContent}
</TooltipHost>
);
},
},
];
const _renderItemColumn = (item: UploadDetailsRecord, index: number, column: IColumn) => {
let fieldContent: string;
const tooltipId = `tooltip-${index}-${column.key}`;
switch (column.key) {
case "status":
fieldContent = `${item.numSucceeded} created, ${item.numThrottled} throttled, ${item.numFailed} errors`;
break;
default:
fieldContent = item.fileName;
}
return (
<TooltipHost content={fieldContent} id={tooltipId} directionalHint={DirectionalHint.rightCenter}>
{fieldContent}
</TooltipHost>
);
};
return (
<RightPaneForm {...props}>
<div className="paneMainContent">
@@ -115,7 +199,6 @@ export const UploadItemsPane: FunctionComponent = () => {
<DetailsList
items={uploadFileData}
columns={columns}
onRenderItemColumn={_renderItemColumn}
selectionMode={SelectionMode.none}
layoutMode={DetailsListLayoutMode.justified}
isHeaderVisible={true}

View File

@@ -22,12 +22,17 @@ export const DeletePopup = ({
};
return (
<Modal isOpen={showDeletePopup} styles={{ main: { minHeight: "122px", minWidth: "880px" } }}>
<Modal
isOpen={showDeletePopup}
styles={{ main: { minHeight: "122px", minWidth: "880px" } }}
titleAriaId="deleteDialogTitle"
subtitleAriaId="deleteDialogSubTitle"
>
<Stack style={{ padding: "16px 24px", height: "auto" }}>
<Text style={{ height: 24, fontSize: "18px" }}>
<Text id="deleteDialogTitle" style={{ height: 24, fontSize: "18px" }}>
<b>Delete code?</b>
</Text>
<Text style={{ marginTop: 10, marginBottom: 20 }}>
<Text id="deleteDialogSubTitle" style={{ marginTop: 10, marginBottom: 20 }}>
This will clear the query from the query builder pane along with all comments and also reset the prompt pane
</Text>
<Stack horizontal tokens={{ childrenGap: 10 }} horizontalAlign="start">

View File

@@ -11,6 +11,8 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa
},
}
}
subtitleAriaId="deleteDialogSubTitle"
titleAriaId="deleteDialogTitle"
>
<Stack
style={
@@ -21,6 +23,7 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa
}
>
<Text
id="deleteDialogTitle"
style={
{
"fontSize": "18px",
@@ -33,6 +36,7 @@ exports[`Delete Popup snapshot test should not render when showDeletePopup is fa
</b>
</Text>
<Text
id="deleteDialogSubTitle"
style={
{
"marginBottom": 20,
@@ -89,6 +93,8 @@ exports[`Delete Popup snapshot test should render when showDeletePopup is true 1
},
}
}
subtitleAriaId="deleteDialogSubTitle"
titleAriaId="deleteDialogTitle"
>
<Stack
style={
@@ -99,6 +105,7 @@ exports[`Delete Popup snapshot test should render when showDeletePopup is true 1
}
>
<Text
id="deleteDialogTitle"
style={
{
"fontSize": "18px",
@@ -111,6 +118,7 @@ exports[`Delete Popup snapshot test should render when showDeletePopup is true 1
</b>
</Text>
<Text
id="deleteDialogSubTitle"
style={
{
"marginBottom": 20,

View File

@@ -13,21 +13,21 @@ import {
SplitButton,
} from "@fluentui/react-components";
import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons";
import { MaterializedViewsLabels } from "Common/Constants";
import { isMaterializedViewsEnabled } from "Common/DatabaseAccountUtility";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext";
import Explorer from "Explorer/Explorer";
import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel";
import {
AddMaterializedViewPanel,
AddMaterializedViewPanelProps,
} from "Explorer/Panes/AddMaterializedViewPanel/AddMaterializedViewPanel";
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { Tabs } from "Explorer/Tabs/Tabs";
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { ResourceTree } from "Explorer/Tree/ResourceTree";
import { useDatabases } from "Explorer/useDatabases";
import { KeyboardAction, KeyboardActionGroup, KeyboardActionHandler, useKeyboardActionGroup } from "KeyboardShortcuts";
import { isFabric, isFabricMirrored, isFabricNative } from "Platform/Fabric/FabricUtil";
import { isFabric, isFabricMirrored, isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext";
import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
import { Allotment, AllotmentHandle } from "allotment";
@@ -168,21 +168,21 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
});
}
if (isMaterializedViewsEnabled()) {
const addMaterializedViewPanelProps: AddMaterializedViewPanelProps = {
if (isGlobalSecondaryIndexEnabled()) {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer,
};
actions.push({
id: "new_materialized_view",
label: MaterializedViewsLabels.NewMaterializedView,
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
icon: <Add16Regular />,
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
MaterializedViewsLabels.NewMaterializedView,
<AddMaterializedViewPanel {...addMaterializedViewPanelProps} />,
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
),
});
}
@@ -318,6 +318,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
const hasGlobalCommands = !(
isFabricMirrored() ||
isFabricNativeReadOnly() ||
userContext.apiType === "Postgres" ||
userContext.apiType === "VCoreMongo"
);
@@ -340,16 +341,18 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
<>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
{!isFabricNative() && (
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
)}
<button
type="button"
className={styles.floatingControlButton}

View File

@@ -1,13 +1,14 @@
/**
* Accordion top class
*/
import { Link, makeStyles, tokens } from "@fluentui/react-components";
import { makeStyles, tokens } from "@fluentui/react-components";
import { DocumentAddRegular, LinkMultipleRegular } from "@fluentui/react-icons";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil";
import * as React from "react";
import { userContext } from "UserContext";
import CosmosDbBlackIcon from "../../../images/CosmosDB_black.svg";
import LinkIcon from "../../../images/Link_blue.svg";
import Explorer from "../Explorer";
export interface SplashScreenProps {
@@ -60,6 +61,15 @@ const useStyles = makeStyles({
margin: "auto",
},
},
single: {
gridColumn: "1 / 4",
gridRow: "1 / 3",
"& svg": {
width: "64px",
height: "64px",
margin: "auto",
},
},
buttonContainer: {
height: "100%",
display: "flex",
@@ -108,12 +118,10 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
onClick,
}) => {
const styles = useStyles();
// TODO Make this a11y copmliant: aria-label for icon
return (
<div role="button" className={`${styles.buttonContainer} ${className}`} onClick={onClick}>
<div className={styles.buttonUpperPart}>{icon}</div>
<div className={styles.buttonLowerPart}>
<div aria-label={title} className={styles.buttonLowerPart}>
<div>{title}</div>
<div>{description}</div>
</div>
@@ -123,6 +131,8 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScreenProps) => {
const styles = useStyles();
const [openSampleDataImportDialog, setOpenSampleDataImportDialog] = React.useState(false);
const getSplashScreenButtons = (): JSX.Element => {
const buttons: FabricHomeScreenButtonProps[] = [
{
@@ -138,15 +148,21 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
title: "Sample data",
description: "Automatically load sample data in your database",
icon: <img src={CosmosDbBlackIcon} />,
onClick: () => setOpenSampleDataImportDialog(true),
},
{
title: "App development",
description: "Start here to use an SDK to build your apps",
icon: <LinkMultipleRegular />,
onClick: () => window.open("https://aka.ms/cosmosdbfabricsdk", "_blank"),
},
];
return (
return isFabricNativeReadOnly() ? (
<div className={styles.buttonsContainer}>
<FabricHomeScreenButton className={styles.single} {...buttons[2]} />
</div>
) : (
<div className={styles.buttonsContainer}>
<FabricHomeScreenButton className={styles.one} {...buttons[0]} />
<FabricHomeScreenButton className={styles.two} {...buttons[1]} />
@@ -155,19 +171,27 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
);
};
const title = "Build your database";
const title = isFabricNativeReadOnly() ? "Use your database" : "Build your database";
return (
<div className={styles.homeContainer}>
<div className={styles.title} role="heading" aria-label={title}>
{title}
</div>
{getSplashScreenButtons()}
<div className={styles.footer}>
Need help?{" "}
<Link href="https://cosmos.azure.com/docs" target="_blank">
Learn more <img src={LinkIcon} alt="Learn more" />
</Link>
</div>
</div>
<>
<CosmosFluentProvider className={styles.homeContainer}>
<SampleDataImportDialog
open={openSampleDataImportDialog}
setOpen={setOpenSampleDataImportDialog}
explorer={props.explorer}
databaseName={userContext.fabricContext?.databaseName}
/>
<div className={styles.title} role="heading" aria-label={title}>
{title}
</div>
{getSplashScreenButtons()}
{/* <div className={styles.footer}>
Need help?{" "}
<Link href="https://aka.ms/cosmosdbfabricdocs" target="_blank">
Learn more <img src={LinkIcon} alt="Learn more" />
</Link>
</div> */}
</CosmosFluentProvider>
</>
);
};

View File

@@ -0,0 +1,158 @@
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
makeStyles,
Spinner,
tokens,
} from "@fluentui/react-components";
import Explorer from "Explorer/Explorer";
import { checkContainerExists, createContainer, importData } from "Explorer/SplashScreen/SampleUtil";
import React, { useEffect, useState } from "react";
import * as ViewModels from "../../Contracts/ViewModels";
const SAMPLE_DATA_CONTAINER_NAME = "SampleData";
const useStyles = makeStyles({
dialogContent: {
alignItems: "center",
marginBottom: tokens.spacingVerticalL,
},
});
/**
* This dialog:
* - creates a container
* - imports data into the container
* @param props
* @returns
*/
export const SampleDataImportDialog: React.FC<{
open: boolean;
setOpen: (open: boolean) => void;
explorer: Explorer;
databaseName: string;
}> = (props) => {
const [status, setStatus] = useState<"idle" | "creating" | "importing" | "completed" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const containerName = SAMPLE_DATA_CONTAINER_NAME;
const [collection, setCollection] = useState<ViewModels.Collection>(undefined);
const styles = useStyles();
useEffect(() => {
// Reset state when dialog opens
if (props.open) {
setStatus("idle");
setErrorMessage(undefined);
}
}, [props.open]);
const handleStartImport = async (): Promise<void> => {
setStatus("creating");
const databaseName = props.databaseName;
if (checkContainerExists(databaseName, containerName)) {
const msg = `The container "${containerName}" in database "${databaseName}" already exists. Please delete it and retry.`;
setStatus("error");
setErrorMessage(msg);
return;
}
let collection;
try {
collection = await createContainer(databaseName, containerName, props.explorer);
} catch (error) {
setStatus("error");
setErrorMessage(`Failed to create container: ${error instanceof Error ? error.message : String(error)}`);
return;
}
try {
setStatus("importing");
await importData(collection);
setCollection(collection);
setStatus("completed");
} catch (error) {
setStatus("error");
setErrorMessage(`Failed to import data: ${error instanceof Error ? error.message : String(error)}`);
}
};
const handleActionOnClick = () => {
switch (status) {
case "idle":
handleStartImport();
break;
case "error":
props.setOpen(false);
break;
case "creating":
case "importing":
props.setOpen(false);
break;
case "completed":
props.setOpen(false);
collection.openTab();
break;
}
};
const renderContent = () => {
switch (status) {
case "idle":
return `Create a container "${containerName}" and import sample data into it. This may take a few minutes.`;
case "creating":
return <Spinner size="small" labelPosition="above" label={`Creating container "${containerName}"...`} />;
case "importing":
return <Spinner size="small" labelPosition="above" label={`Importing data into "${containerName}"...`} />;
case "completed":
return `Successfully created "${containerName}" with sample data.`;
case "error":
return (
<div style={{ color: "red" }}>
<div>Error: {errorMessage}</div>
</div>
);
}
};
const getButtonLabel = () => {
switch (status) {
case "idle":
return "Start";
case "creating":
case "importing":
return "Close";
case "completed":
return "Close";
case "error":
return "Close";
}
};
return (
<Dialog open={props.open} onOpenChange={(event, data) => props.setOpen(data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle>Sample Data</DialogTitle>
<DialogContent>
<div className={styles.dialogContent}>{renderContent()}</div>
</DialogContent>
<DialogActions>
<Button
appearance="primary"
onClick={handleActionOnClick}
disabled={status === "creating" || status === "importing"}
>
{getButtonLabel()}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
};

View File

@@ -0,0 +1,64 @@
import { BackendDefaults } from "Common/Constants";
import { createCollection } from "Common/dataAccess/createCollection";
import Explorer from "Explorer/Explorer";
import { useDatabases } from "Explorer/useDatabases";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
/**
* Public for unit tests
* @param databaseName
* @param containerName
* @param containerDatabases
*/
const hasContainer = (
databaseName: string,
containerName: string,
containerDatabases: ViewModels.Database[],
): boolean => {
const filteredDatabases = containerDatabases.filter((database) => database.id() === databaseName);
return (
filteredDatabases.length > 0 &&
filteredDatabases[0].collections().filter((collection) => collection.id() === containerName).length > 0
);
};
export const checkContainerExists = (databaseName: string, containerName: string) =>
hasContainer(databaseName, containerName, useDatabases.getState().databases);
export const createContainer = async (
databaseName: string,
containerName: string,
explorer: Explorer,
): Promise<ViewModels.Collection> => {
const createRequest: DataModels.CreateCollectionParams = {
createNewDatabase: false,
collectionId: containerName,
databaseId: databaseName,
databaseLevelThroughput: false,
partitionKey: {
paths: [`/${SAMPLE_DATA_PARTITION_KEY}`],
kind: "Hash",
version: BackendDefaults.partitionKeyVersion,
},
};
await createCollection(createRequest);
await explorer.refreshAllDatabases();
const database = useDatabases.getState().findDatabaseWithId(databaseName);
if (!database) {
return undefined;
}
await database.loadCollections();
const newCollection = database.findCollectionWithId(containerName);
return newCollection;
};
const SAMPLE_DATA_PARTITION_KEY = "category"; // This pkey is specifically set for queryCopilotSampleData.json below
export const importData = async (collection: ViewModels.Collection): Promise<void> => {
// TODO: keep same chunk as ContainerSampleGenerator
const dataFileContent = await import(
/* webpackChunkName: "queryCopilotSampleData" */ "../../../sampleData/queryCopilotSampleData.json"
);
await collection.bulkInsertDocuments(dataFileContent.data);
};

View File

@@ -817,7 +817,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
private vcoreMongoNextStepItems: { link: string; title: string; description: string }[] = [
{
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/how-to-migrate-native-tools?tabs=export-import",
link: "https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options",
title: "Migrate Data",
description: "",
},

View File

@@ -0,0 +1,80 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import React, { useEffect, useRef } from "react";
import "xterm/css/xterm.css";
import { DatabaseAccount } from "../../../Contracts/DataModels";
import { TerminalKind } from "../../../Contracts/ViewModels";
import { startCloudShellTerminal } from "./CloudShellTerminalCore";
export interface CloudShellTerminalComponentProps {
databaseAccount: DatabaseAccount;
tabId: string;
username?: string;
shellType?: TerminalKind;
}
export const CloudShellTerminalComponent: React.FC<CloudShellTerminalComponentProps> = (props) => {
const terminalRef = useRef(null); // Reference for terminal container
const xtermRef = useRef(null); // Reference for XTerm instance
const socketRef = useRef(null); // Reference for WebSocket
useEffect(() => {
// Initialize XTerm instance
const terminal = new Terminal({
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "monospace",
fontSize: 11,
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#ffcc00",
},
scrollback: 1000,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
// Attach terminal to the DOM
if (terminalRef.current) {
terminal.open(terminalRef.current);
xtermRef.current = terminal;
}
// Defer terminal sizing until after DOM rendering is complete
setTimeout(() => {
fitAddon.fit();
}, 0);
// Use ResizeObserver instead of window resize
const resizeObserver = new ResizeObserver(() => {
const container = terminalRef.current;
if (container && container.offsetWidth > 0 && container.offsetHeight > 0) {
try {
fitAddon.fit();
} catch (e) {
console.warn("Fit failed on resize:", e);
}
}
});
resizeObserver.observe(terminalRef.current);
socketRef.current = startCloudShellTerminal(terminal, props.shellType);
// Cleanup function to close WebSocket and dispose terminal
return () => {
if (!socketRef.current) {
return;
}
if (socketRef.current && socketRef.current.readyState && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.close(); // Close WebSocket connection
}
if (resizeObserver && terminalRef.current) {
resizeObserver.unobserve(terminalRef.current);
}
terminal.dispose(); // Clean up XTerm instance
};
}, []);
return <div ref={terminalRef} style={{ width: "100%", height: "500px" }} />;
};

View File

@@ -0,0 +1,309 @@
import { Terminal } from "@xterm/xterm";
import { Areas } from "../../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
import { TerminalKind } from "../../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext";
import {
connectTerminal,
provisionConsole,
putEphemeralUserSettings,
registerCloudShellProvider,
verifyCloudShellProviderRegistration,
} from "./Data/CloudShellClient";
import { CloudShellProviderInfo, ProvisionConsoleResponse } from "./Models/DataModels";
import { AbstractShellHandler, START_MARKER } from "./ShellTypes/AbstractShellHandler";
import { getHandler } from "./ShellTypes/ShellTypeFactory";
import { AttachAddon } from "./Utils/AttachAddOn";
import { askConfirmation, wait } from "./Utils/CommonUtils";
import { getNormalizedRegion } from "./Utils/RegionUtils";
import { formatErrorMessage, formatInfoMessage, formatWarningMessage } from "./Utils/TerminalLogFormats";
// Constants
const DEFAULT_CLOUDSHELL_REGION = "westus";
const POLLING_INTERVAL_MS = 2000;
const MAX_RETRY_COUNT = 10;
const MAX_PING_COUNT = 120 * 60; // 120 minutes (60 seconds/minute)
let pingCount = 0;
let keepAliveID: NodeJS.Timeout = null;
/**
* Main function to start a CloudShell terminal
*/
export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind): Promise<WebSocket> => {
const startKey = TelemetryProcessor.traceStart(Action.CloudShellTerminalSession, {
shellType: TerminalKind[shellType],
dataExplorerArea: Areas.CloudShell,
});
let resolvedRegion: string;
try {
await ensureCloudShellProviderRegistered();
resolvedRegion = determineCloudShellRegion();
resolvedRegion = determineCloudShellRegion();
terminal.writeln(formatWarningMessage("⚠️ IMPORTANT: Azure Cloud Shell Region Notice ⚠️"));
terminal.writeln(
formatInfoMessage(
"The Cloud Shell environment will operate in a region that may differ from your database's region.",
),
);
terminal.writeln(formatInfoMessage("This has two potential implications:"));
terminal.writeln(formatInfoMessage("1. Performance Impact:"));
terminal.writeln(
formatInfoMessage(" Commands may experience higher latency due to geographic distance between regions."),
);
terminal.writeln(formatInfoMessage("2. Data Compliance Considerations:"));
terminal.writeln(
formatInfoMessage(
" Data processed through this shell could temporarily reside in a different geographic region,",
),
);
terminal.writeln(
formatInfoMessage(" which may affect compliance with data residency requirements or regulations specific"),
);
terminal.writeln(formatInfoMessage(" to your organization."));
terminal.writeln("");
terminal.writeln("\x1b[94mFor more information on Azure Cosmos DB data governance and compliance, please visit:");
terminal.writeln("\x1b[94mhttps://learn.microsoft.com/en-us/azure/cosmos-db/data-residency\x1b[0m");
// Ask for user consent for region
const consentGranted = await askConfirmation(terminal, formatWarningMessage("Do you wish to proceed?"));
// Track user decision
TelemetryProcessor.trace(
Action.CloudShellUserConsent,
consentGranted ? ActionModifiers.Success : ActionModifiers.Cancel,
{
dataExplorerArea: Areas.CloudShell,
shellType: TerminalKind[shellType],
isConsent: consentGranted,
region: resolvedRegion,
},
startKey,
);
if (!consentGranted) {
terminal.writeln(
formatErrorMessage("Session ended. Please close this tab and initiate a new shell session if needed."),
);
return null; // Exit if user declined
}
terminal.writeln(formatInfoMessage("Connecting to CloudShell. This may take a moment. Please wait..."));
const sessionDetails: {
socketUri?: string;
provisionConsoleResponse?: ProvisionConsoleResponse;
targetUri?: string;
} = await provisionCloudShellSession(resolvedRegion, terminal);
if (!sessionDetails.socketUri) {
terminal.writeln(formatErrorMessage("Failed to establish a connection. Please try again later."));
return null;
}
// Get the shell handler for this type
const shellHandler = await getHandler(shellType);
// Configure WebSocket connection with shell-specific commands
const socket = await establishTerminalConnection(terminal, shellHandler, sessionDetails.socketUri);
TelemetryProcessor.traceSuccess(
Action.CloudShellTerminalSession,
{
shellType: TerminalKind[shellType],
dataExplorerArea: Areas.CloudShell,
region: resolvedRegion,
socketUri: sessionDetails.socketUri,
},
startKey,
);
return socket;
} catch (err) {
TelemetryProcessor.traceFailure(
Action.CloudShellTerminalSession,
{
shellType: TerminalKind[shellType],
dataExplorerArea: Areas.CloudShell,
region: resolvedRegion,
error: getErrorMessage(err),
errorStack: getErrorStack(err),
},
startKey,
);
terminal.writeln(formatErrorMessage(`Failed with error.${getErrorMessage(err)}`));
return null;
}
};
/**
* Ensures that the CloudShell provider is registered for the current subscription
*/
export const ensureCloudShellProviderRegistered = async (): Promise<void> => {
const response: CloudShellProviderInfo = await verifyCloudShellProviderRegistration(userContext.subscriptionId);
if (response.registrationState !== "Registered") {
await registerCloudShellProvider(userContext.subscriptionId);
}
};
/**
* Determines the appropriate CloudShell region
*/
export const determineCloudShellRegion = (): string => {
return getNormalizedRegion(userContext.databaseAccount?.location, DEFAULT_CLOUDSHELL_REGION);
};
/**
* Provisions a CloudShell session
*/
export const provisionCloudShellSession = async (
resolvedRegion: string,
terminal: Terminal,
): Promise<{ socketUri?: string; provisionConsoleResponse?: ProvisionConsoleResponse; targetUri?: string }> => {
// Apply user settings
await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion);
// Provision console
let provisionConsoleResponse;
let attemptCounter = 0;
do {
provisionConsoleResponse = await provisionConsole(resolvedRegion);
attemptCounter++;
if (provisionConsoleResponse.properties.provisioningState === "Failed") {
break;
}
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
await wait(POLLING_INTERVAL_MS);
}
} while (provisionConsoleResponse.properties.provisioningState !== "Succeeded" && attemptCounter < MAX_RETRY_COUNT);
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
throw new Error(`Provisioning failed: ${provisionConsoleResponse.properties.provisioningState}`);
}
// Connect terminal
const connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, {
rows: terminal.rows,
cols: terminal.cols,
});
const targetUri = `${provisionConsoleResponse.properties.uri}/terminals?cols=${terminal.cols}&rows=${terminal.rows}&version=2019-01-01&shell=bash`;
const termId = connectTerminalResponse.id;
// Determine socket URI
let socketUri = connectTerminalResponse.socketUri.replace(":443/", "");
const targetUriBody = targetUri.replace("https://", "").split("?")[0];
// This socket URI transformation logic handles different Azure service endpoint formats.
// If the returned socketUri doesn't contain the expected host, we construct it manually.
// This ensures compatibility across different Azure regions and deployment configurations.
if (socketUri.indexOf(targetUriBody) === -1) {
socketUri = `wss://${targetUriBody}/${termId}`;
}
// Special handling for ServiceBus-based endpoints which require a specific URI format
// with the hierarchical connection ($hc) path segment for terminal connections
if (targetUriBody.includes("servicebus")) {
const targetUriBodyArr = targetUriBody.split("/");
socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`;
}
return { socketUri, provisionConsoleResponse, targetUri };
};
/**
* Establishes a terminal connection via WebSocket
*/
export const establishTerminalConnection = async (
terminal: Terminal,
shellHandler: AbstractShellHandler,
socketUri: string,
): Promise<WebSocket> => {
let socket = new WebSocket(socketUri);
// Get shell-specific initial commands
const initCommands = shellHandler.getInitialCommands();
// Configure the socket
socket = await configureSocketConnection(socket, socketUri, terminal, initCommands, 0);
const options = {
startMarker: START_MARKER,
shellHandler: shellHandler,
};
// Attach the terminal addon
const attachAddon = new AttachAddon(socket, options);
terminal.loadAddon(attachAddon);
return socket;
};
/**
* Configures a WebSocket connection for the terminal
*/
export const configureSocketConnection = async (
socket: WebSocket,
uri: string,
terminal: Terminal,
initCommands: string,
socketRetryCount: number,
): Promise<WebSocket> => {
sendTerminalStartupCommands(socket, initCommands);
socket.onerror = async () => {
if (socketRetryCount < MAX_RETRY_COUNT && socket.readyState !== WebSocket.CLOSED) {
await configureSocketConnection(socket, uri, terminal, initCommands, socketRetryCount + 1);
} else {
socket.close();
}
};
socket.onclose = () => {
if (keepAliveID) {
clearTimeout(keepAliveID);
pingCount = 0;
}
};
return socket;
};
export const sendTerminalStartupCommands = (socket: WebSocket, initCommands: string): void => {
// ensures connections don't remain open indefinitely by implementing an automatic timeout after 120 minutes.
const keepSocketAlive = (socket: WebSocket) => {
if (socket.readyState === WebSocket.OPEN) {
if (pingCount >= MAX_PING_COUNT) {
socket.close();
} else {
pingCount++;
// The code uses a recursive setTimeout pattern rather than setInterval,
// which ensures each new ping only happens after the previous one completes
// and naturally stops if the socket closes.
keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000);
}
}
};
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(initCommands);
keepSocketAlive(socket);
} else {
socket.onopen = () => {
socket.send(initCommands);
keepSocketAlive(socket);
};
}
};

View File

@@ -0,0 +1,337 @@
import { armRequest } from "../../../../Utils/arm/request";
import { NetworkType, OsType, SessionType, ShellType } from "../Models/DataModels";
import {
connectTerminal,
getUserSettings,
provisionConsole,
putEphemeralUserSettings,
registerCloudShellProvider,
verifyCloudShellProviderRegistration,
} from "./CloudShellClient";
// Instead of redeclaring fetch, modify the global context
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace NodeJS {
interface Global {
fetch: jest.Mock;
}
}
}
/* eslint-enable @typescript-eslint/no-namespace */
// Define mock endpoint
const MOCK_ARM_ENDPOINT = "https://mock-management.azure.com";
// Mock dependencies
jest.mock("uuid", () => ({
v4: jest.fn().mockReturnValue("mocked-uuid"),
}));
jest.mock("../../../../ConfigContext", () => ({
configContext: {
ARM_ENDPOINT: "https://mock-management.azure.com",
},
}));
jest.mock("../../../../UserContext", () => ({
userContext: {
authorizationToken: "Bearer mock-token",
},
}));
jest.mock("../../../../Utils/arm/request");
jest.mock("../Utils/CommonUtils", () => ({
getLocale: jest.fn().mockReturnValue("en-US"),
}));
// Properly mock fetch with correct typings
const mockJsonPromise = jest.fn();
global.fetch = jest.fn().mockImplementationOnce(() => {
return {
ok: true,
status: 200,
json: mockJsonPromise,
text: jest.fn().mockResolvedValue(""),
headers: new Headers(),
} as unknown as Promise<Response>;
}) as jest.Mock;
describe("CloudShellClient", () => {
beforeEach(() => {
jest.clearAllMocks();
mockJsonPromise.mockClear();
});
// Reset all mocks after all tests
afterAll(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
if (global.fetch) {
delete global.fetch;
}
});
describe("getUserSettings", () => {
it("should call armRequest with correct parameters and return settings", async () => {
const mockSettings = { properties: { preferredLocation: "eastus" } };
(armRequest as jest.Mock).mockResolvedValueOnce(mockSettings);
const result = await getUserSettings();
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
method: "GET",
apiVersion: "2023-02-01-preview",
});
expect(result).toEqual(mockSettings);
});
it("should handle errors when settings retrieval fails", async () => {
const mockError = new Error("Failed to get user settings");
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
await expect(getUserSettings()).rejects.toThrow("Failed to get user settings");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
method: "GET",
apiVersion: "2023-02-01-preview",
});
});
});
describe("putEphemeralUserSettings", () => {
it("should call armRequest with default network settings", async () => {
const mockResponse = { id: "settings-id" };
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
const result = await putEphemeralUserSettings("sub-id", "eastus");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
method: "PUT",
apiVersion: "2023-02-01-preview",
body: {
properties: {
preferredOsType: OsType.Linux,
preferredShellType: ShellType.Bash,
preferredLocation: "eastus",
networkType: NetworkType.Default,
sessionType: SessionType.Ephemeral,
userSubscription: "sub-id",
vnetSettings: {},
},
},
});
expect(result).toEqual(mockResponse);
});
it("should call armRequest with isolated network settings", async () => {
const mockVNetSettings = { subnetId: "test-subnet" };
const mockResponse = { id: "settings-id" };
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
await putEphemeralUserSettings("sub-id", "eastus", mockVNetSettings);
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/providers/Microsoft.Portal/userSettings/cloudconsole",
method: "PUT",
apiVersion: "2023-02-01-preview",
body: {
properties: {
preferredOsType: OsType.Linux,
preferredShellType: ShellType.Bash,
preferredLocation: "eastus",
networkType: NetworkType.Isolated,
sessionType: SessionType.Ephemeral,
userSubscription: "sub-id",
vnetSettings: mockVNetSettings,
},
},
});
});
it("should handle errors when updating settings fails", async () => {
const mockError = new Error("Failed to update user settings");
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
await expect(putEphemeralUserSettings("sub-id", "eastus")).rejects.toThrow("Failed to update user settings");
expect(armRequest).toHaveBeenCalled();
});
});
describe("verifyCloudShellProviderRegistration", () => {
it("should call armRequest with correct parameters", async () => {
const mockResponse = { registrationState: "Registered" };
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
const result = await verifyCloudShellProviderRegistration("sub-id");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell",
method: "GET",
apiVersion: "2022-12-01",
});
expect(result).toEqual(mockResponse);
});
it("should handle errors when verification fails", async () => {
const mockError = new Error("Failed to verify provider registration");
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
await expect(verifyCloudShellProviderRegistration("sub-id")).rejects.toThrow(
"Failed to verify provider registration",
);
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell",
method: "GET",
apiVersion: "2022-12-01",
});
});
});
describe("registerCloudShellProvider", () => {
it("should call armRequest with correct parameters", async () => {
const mockResponse = { operationId: "op-id" };
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
const result = await registerCloudShellProvider("sub-id");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell/register",
method: "POST",
apiVersion: "2022-12-01",
});
expect(result).toEqual(mockResponse);
});
it("should handle errors when registration fails", async () => {
const mockError = new Error("Failed to register provider");
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
await expect(registerCloudShellProvider("sub-id")).rejects.toThrow("Failed to register provider");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "/subscriptions/sub-id/providers/Microsoft.CloudShell/register",
method: "POST",
apiVersion: "2022-12-01",
});
});
});
describe("provisionConsole", () => {
it("should call armRequest with correct parameters", async () => {
const mockResponse = { uri: "https://shell.azure.com/console123" };
(armRequest as jest.Mock).mockResolvedValueOnce(mockResponse);
const result = await provisionConsole("eastus");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "providers/Microsoft.Portal/consoles/default",
method: "PUT",
apiVersion: "2023-02-01-preview",
customHeaders: {
"x-ms-console-preferred-location": "eastus",
},
body: {
properties: {
osType: OsType.Linux,
},
},
});
expect(result).toEqual(mockResponse);
});
it("should handle errors when console provisioning fails", async () => {
const mockError = new Error("Failed to provision console");
(armRequest as jest.Mock).mockRejectedValueOnce(mockError);
await expect(provisionConsole("eastus")).rejects.toThrow("Failed to provision console");
expect(armRequest).toHaveBeenCalledWith({
host: MOCK_ARM_ENDPOINT,
path: "providers/Microsoft.Portal/consoles/default",
method: "PUT",
apiVersion: "2023-02-01-preview",
customHeaders: {
"x-ms-console-preferred-location": "eastus",
},
body: {
properties: {
osType: OsType.Linux,
},
},
});
});
});
describe("connectTerminal", () => {
it("should call fetch with correct parameters", async () => {
const consoleUri = "https://shell.azure.com/console123";
const size = { rows: 24, cols: 80 };
const mockTerminalResponse = { id: "terminal-id", socketUri: "wss://shell.azure.com/socket" };
// Setup the mock response
mockJsonPromise.mockResolvedValueOnce(mockTerminalResponse);
const result = await connectTerminal(consoleUri, size);
expect(global.fetch).toHaveBeenCalledWith(
"https://shell.azure.com/console123/terminals?cols=80&rows=24&version=2019-01-01&shell=bash",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Length": "2",
Authorization: "Bearer mock-token",
"x-ms-client-request-id": "mocked-uuid",
"Accept-Language": "en-US",
},
body: "{}",
},
);
expect(mockJsonPromise).toHaveBeenCalled();
expect(result).toEqual(mockTerminalResponse);
});
it("should handle errors when terminal connection fails", async () => {
const consoleUri = "https://shell.azure.com/console123";
const size = { rows: 24, cols: 80 };
// Mock fetch to return a failed response
global.fetch = jest.fn().mockImplementationOnce(() => {
return {
ok: false,
status: 500,
statusText: "Internal Server Error",
json: jest.fn().mockRejectedValue(new Error("Failed to parse JSON")),
text: jest.fn().mockResolvedValue("Server Error"),
headers: new Headers(),
} as unknown as Promise<Response>;
});
await expect(connectTerminal(consoleUri, size)).rejects.toThrow(
"Failed to connect to terminal: 500 Internal Server Error",
);
expect(global.fetch).toHaveBeenCalledWith(
"https://shell.azure.com/console123/terminals?cols=80&rows=24&version=2019-01-01&shell=bash",
expect.any(Object),
);
});
});
});

View File

@@ -0,0 +1,117 @@
import { v4 as uuidv4 } from "uuid";
import { configContext } from "../../../../ConfigContext";
import { userContext } from "../../../../UserContext";
import { armRequest } from "../../../../Utils/arm/request";
import {
CloudShellProviderInfo,
CloudShellSettings,
ConnectTerminalResponse,
NetworkType,
OsType,
ProvisionConsoleResponse,
SessionType,
ShellType,
} from "../Models/DataModels";
import { getLocale } from "../Utils/CommonUtils";
export const getUserSettings = async (): Promise<CloudShellSettings> => {
return await armRequest<CloudShellSettings>({
host: configContext.ARM_ENDPOINT,
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
method: "GET",
apiVersion: "2023-02-01-preview",
});
};
export const putEphemeralUserSettings = async (
userSubscriptionId: string,
userRegion: string,
vNetSettings?: object,
) => {
const ephemeralSettings: CloudShellSettings = {
properties: {
preferredOsType: OsType.Linux,
preferredShellType: ShellType.Bash,
preferredLocation: userRegion,
networkType:
!vNetSettings || Object.keys(vNetSettings).length === 0
? NetworkType.Default
: vNetSettings
? NetworkType.Isolated
: NetworkType.Default,
sessionType: SessionType.Ephemeral,
userSubscription: userSubscriptionId,
vnetSettings: vNetSettings ?? {},
},
};
return await armRequest({
host: configContext.ARM_ENDPOINT,
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
method: "PUT",
apiVersion: "2023-02-01-preview",
body: ephemeralSettings,
});
};
export const verifyCloudShellProviderRegistration = async (subscriptionId: string): Promise<CloudShellProviderInfo> => {
return await armRequest({
host: configContext.ARM_ENDPOINT,
path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell`,
method: "GET",
apiVersion: "2022-12-01",
});
};
export const registerCloudShellProvider = async (subscriptionId: string) => {
return await armRequest({
host: configContext.ARM_ENDPOINT,
path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell/register`,
method: "POST",
apiVersion: "2022-12-01",
});
};
export const provisionConsole = async (consoleLocation: string): Promise<ProvisionConsoleResponse> => {
const data = {
properties: {
osType: OsType.Linux,
},
};
return await armRequest<ProvisionConsoleResponse>({
host: configContext.ARM_ENDPOINT,
path: `providers/Microsoft.Portal/consoles/default`,
method: "PUT",
apiVersion: "2023-02-01-preview",
customHeaders: {
"x-ms-console-preferred-location": consoleLocation,
},
body: data,
});
};
export const connectTerminal = async (
consoleUri: string,
size: { rows: number; cols: number },
): Promise<ConnectTerminalResponse> => {
const targetUri = consoleUri + `/terminals?cols=${size.cols}&rows=${size.rows}&version=2019-01-01&shell=bash`;
const resp = await fetch(targetUri, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Length": "2",
Authorization: userContext.authorizationToken,
"x-ms-client-request-id": uuidv4(),
"Accept-Language": getLocale(),
},
body: "{}", // empty body is necessary
});
if (!resp.ok) {
throw new Error(`Failed to connect to terminal: ${resp.status} ${resp.statusText}`);
}
return resp.json();
};

View File

@@ -0,0 +1,91 @@
export const enum OsType {
Linux = "linux",
Windows = "windows",
}
export const enum ShellType {
Bash = "bash",
PowerShellCore = "pwsh",
}
export const enum NetworkType {
Default = "Default",
Isolated = "Isolated",
}
/**
* Azure CloudShell session types:
* - Mounted: Sessions with persistent storage via an Azure File Share mount.
* Files and configurations are preserved between sessions, allowing for
* continuity of work across multiple CloudShell sessions.
*
* - Ephemeral: Temporary sessions without persistent storage.
* All files and changes are discarded when the session ends.
* These sessions start faster but don't retain user data.
*
* The session type affects resource allocation, startup time,
* and whether user files/configurations persist between sessions.
*/
export const enum SessionType {
Mounted = "Mounted",
Ephemeral = "Ephemeral",
}
export type CloudShellSettings = {
properties: UserSettingProperties;
};
export type UserSettingProperties = {
networkType: string;
preferredLocation: string;
preferredOsType: OsType;
preferredShellType: ShellType;
userSubscription: string;
sessionType: SessionType;
vnetSettings: object;
};
export type ProvisionConsoleResponse = {
properties: {
osType: OsType;
provisioningState: string;
uri: string;
};
};
export type Authorization = {
token: string;
};
export type ConnectTerminalResponse = {
id: string;
idleTimeout: string;
rootDirectory: string;
socketUri: string;
tokenUpdated: boolean;
};
export type ProviderAuthorization = {
applicationId: string;
roleDefinitionId: string;
};
export type ProviderResourceType = {
resourceType: string;
locations: string[];
apiVersions: string[];
defaultApiVersion?: string;
capabilities?: string;
};
export type RegistrationState = "Registered" | "NotRegistered" | "Registering" | "Unregistering";
export type RegistrationPolicy = "RegistrationRequired" | "RegistrationOptional";
export type CloudShellProviderInfo = {
id: string;
namespace: string;
authorizations?: ProviderAuthorization[];
resourceTypes: ProviderResourceType[];
registrationState: RegistrationState;
registrationPolicy: RegistrationPolicy;
};

View File

@@ -0,0 +1,282 @@
# Migrate Mongo(RU/vCore)/Postgres/Cassandra shell to CloudShell Design
## CloudShell Overview
Cloud Shell provides an integrated terminal experience directly within Cosmos Explorer, allowing users to interact with different database engines using their native command-line interfaces.
## Component Architecture
```mermaid
classDiagram
class FeatureRegistration {
<<Registers a new flag for switching shell to CloudShell>>
+enableCloudShell: boolean
}
class ShellTypeHandlerFactory {
<<Initialize corresponding handler based on the type of shell>>
+getHandler(terminalKind: TerminalKind): ShellTypeHandler
+getKey(): string
}
class AbstractShellHandler {
<<interface>>
+getShellName(): string
+getSetUpCommands(): string[]
+getConnectionCommand(): string
+getEndpoint(): string
+getTerminalSuppressedData(): string[]
+getInitialCommands(): string
}
class CloudShellTerminalComponent {
<<React Component to Render CloudShell>>
-terminalKind: TerminalKind
-shellHandler: AbstractShellHandler
+render(): ReactElement
}
class CloudShellTerminalCore {
<<Initialize CloudShell>>
+startCloudShellTerminal()
}
class CloudShellClient {
<Initialize CloudShell APIs>
+getUserSettings(): Promise
+putEphemeralUserSettings(): void
+verifyCloudShellProviderRegistration: void
+registerCloudShellProvider(): void
+provisionConsole(): ProvisionConsoleResponse
+connectTerminal(): ConnectTerminalResponse
+authorizeSession(): Authorization
}
class CloudShellTerminalComponentAdapter {
+getDatabaseAccount: DataModels.DatabaseAccount,
+getTabId: string,
+getUsername: string,
+isAllPublicIPAddressesEnabled: ko.Observable<boolean>,
+kind: ViewModels.TerminalKind,
}
class TerminalTab {
-cloudShellTerminalComponentAdapter: CloudShellTerminalComponentAdapter
}
class ContextMenuButtonFactory {
+getCloudShellButton(): ReactElement
+isCloudShellEnabled(): boolean
}
UserContext --> FeatureRegistration : contains
FeatureRegistration ..> ContextMenuButtonFactory : controls UI visibility
FeatureRegistration ..> CloudShellTerminalComponentAdapter : enables tab creation
FeatureRegistration ..> CloudShellClient : permits API calls
TerminalTab --> CloudShellTerminalComponentAdapter : manages
ContextMenuButtonFactory --> TerminalTab : creates
TerminalTab --> CloudShellTerminalComponent : renders
CloudShellTerminalComponent --> CloudShellTerminalCore : contains
CloudShellTerminalComponent --> ShellTypeHandlerFactory : uses
CloudShellTerminalCore --> CloudShellClient : communicates with
CloudShellTerminalCore --> AbstractShellHandler : uses configuration from
ShellTypeHandlerFactory --> AbstractShellHandler : creates
class MongoShellHandler {
-key: string
+getShellName(): string
+getSetUpCommands(): string[]
+getConnectionCommand(): string
+getEndpoint(): string
+getTerminalSuppressedData(): string[]
+getInitialCommands(): string
class VCoreMongoShellHandler {
+getShellName(): string
+getSetUpCommands(): string[]
+getConnectionCommand(): string
+getEndpoint(): string
+getTerminalSuppressedData(): string[]
+getInitialCommands(): string
}
class CassandraShellHandler {
-key: string
+getShellName(): string
+getSetUpCommands(): string[]
+getConnectionCommand(): string
+getEndpoint(): string
+getTerminalSuppressedData(): string[]
+getInitialCommands(): string
}
class PostgresShellHandler {
+getShellName(): string
+getSetUpCommands(): string[]
+getConnectionCommand(): string
+getEndpoint(): string
+getTerminalSuppressedData(): string[]
+getInitialCommands(): string
}
AbstractShellHandler <|.. MongoShellHandler
AbstractShellHandler <|.. VCoreMongoShellHandler
AbstractShellHandler <|.. CassandraShellHandler
AbstractShellHandler <|.. PostgresShellHandler
```
## Changes
The CloudShell functionality is controlled by the feature flag `userContext.features.enableCloudShell`. When this flag is **enabled** (set to true), the following occurs in the application:
1. **UI Components Become Available:** There is "Open Mongo Shell" or similar button appears on data explorer or quick start window.
2. **Service Capabilities Are Activated:**
- Backend API calls to CloudShell services are permitted
- Terminal connection endpoints become accessible
3. **Database-Specific Features Are Unlocked:**
- Terminal experiences tailored to each database type become available
- Shell handlers are instantiated based on the database type
4. **Telemetry Collection Begins:**
- When CloudShell Starts
- User Consent to access shell out of the region
- When shell is connected
- When there is an error during CloudShell initialization
The feature can be enabled by putting `feature.enableCloudShell=true` in url.
When disabled, all CloudShell functionality is hidden and inaccessible, ensuring a consistent user experience regardless of the feature's state. These shell would be talking to tools federation.
## Supported Shell Types
| Terminal Kind | Handler Class | Description |
|---------------|--------------|-------------|
| Mongo | MongoShellHandler | Handles MongoDB RU shell connections |
| VCoreMongo | VCoreMongoShellHandler | Handles for VCore MongoDB shell connections |
| Cassandra | CassandraShellHandler | Handles Cassandra shell connections |
| Postgres | PostgresShellHandler | Handles PostgreSQL shell connections |
## Implementation Details
The CloudShell implementation uses the Factory pattern to create appropriate shell handlers based on the database type. Each handler implements the common interface but provides specialized behavior for connecting to different database engines.
### Key Components
1. **ShellTypeHandlerFactory**: Creates the appropriate handler based on terminal kind
- Retrieves authentication keys from Azure Resource Manager
- Instantiates specialized handlers with configuration
2. **ShellTypeHandler Interface i.e. AbstractShellHandler**: Defines the contract for all shell handlers
- `getConnectionCommand()`: Returns shell command to connect to database
- `getSetUpCommands()`: Returns list of scripts required to set up the environment
- `getEndpoint()`: Returns database connection end point
- `getTerminalSuppressedData()`: Returns a string which needs to be suppressed
3. **Specialized Handlers**: Implement specific connection logic for each database type
- Handle authentication differences
- Provide appropriate shell arguments
- Format connection strings correctly
4. **CloudShellTerminalComponent**: React component that renders the terminal interface
- Receives the terminal type as a property
- Uses ShellTypeHandlerFactory to get the appropriate handler
- Renders the CloudShellTerminalCore with the handler's configuration
- Manages component lifecycle and state
5. **CloudShellTerminalCore**: Core terminal implementation
- Handles low-level terminal operations
- Uses the configuration from ShellTypeHandler to initialize the terminal
- Manages input/output streams between the user interface and the shell process
- Handles terminal events (resize, data, etc.)
- Implements terminal UI and styling
6. **CloudShellClient**: Client for interacting with CloudShell backend services
- Initializes the terminal session with backend services
- Manages communication between the terminal UI and the backend shell process
- Handles authentication and security for the terminal session
7. **ContextMenuButtonFactory**: Creates CloudShell UI entry points
- Checks if CloudShell is enabled via `userContext.features.enableCloudShell`
- Generates appropriate terminal buttons based on database type
- Handles conditional rendering of CloudShell options
8. **TerminalTab**: Container component for terminal experiences
- Renders appropriate terminal type based on the selected database
- Manages terminal tab state and lifecycle
- Provides the integration point between the terminal and the rest of the Cosmos Explorer UI
## Telemetry Collection
CloudShell components utilize `TelemetryProcessor.trace` to collect usage data and diagnostics information that help improve the service and troubleshoot issues.
### Telemetry Events
- When CloudShell Starts
- User Consent to access shell out of the region
- When shell is connected
- When there is an error during CloudShell initialization
| Action Name | Description | Collected Data |
|------------|------------|----------------|
| CloudShellTerminalSession/Start | Triggered when user starts a CloudShell session | Shell Type, dataExplorerArea as <i>CloudShell</i>|
| CloudShellUserConsent/(Success/Failure) | Records user consent to get cloudshell in other region | |
| CloudShellTerminalSession/Success | Records if Terminal creation is successful | Shell Type, Shell Region |
| CloudShellTerminalSession/Failure | Records of terminal creation is failed | Shell Type, Shell region (if available), error message |
### Real-time Use Cases
1. **Performance Monitoring**:
- Track shell initialization times across different regions and database types
2. **Error Detection and Resolution**:
- Detect increased error rates in real-time
- Identify patterns in failures
- Correlate errors with specific client configurations
3. **Feature Adoption Analysis**:
- Measure adoption rates of different terminal types
4. **User Experience Optimization**:
- Analyze session duration to understand engagement
- Identify abandoned sessions and potential pain points
- Measure the impact of new features on usage patterns
- Track command completion rates and error recovery
## Limitations and Regional Availability
### Network Isolation
Network isolation (such as private endpoints, service endpoints, and VNet integration) is not currently supported for CloudShell connections. All connections to database instances through CloudShell require the database to be accessible through public endpoints.
Key limitations:
- Cannot connect to databases with public network access disabled
- No support for private link resources
- No integration with Azure Virtual Networks
- IP-based firewall rules must include CloudShell service IPs
### Data Residency
Data residency requirements may not be fully satisfied when using CloudShell due to limited regional availability. CloudShell services are currently available in the following regions:
| Geography | Regions |
|-----------|---------|
| Americas | East US, West US 2, South Central US, West Central US |
| Europe | West Europe, North Europe |
| Asia Pacific | Southeast Asia, Japan East, Australia East |
| Middle East | UAE North |
**Note:** For up-to-date supported regions, refer to the region configuration in:
`src/Explorer/CloudShell/Configuration/RegionConfig.ts`
### Implications for Compliance
Organizations with strict data residency or network isolation requirements should be aware of these limitations:
1. Data may transit through regions different from the database region
2. Terminal session data is processed in CloudShell regions, not necessarily the database region
3. Commands and queries are executed through CloudShell services, not directly against the database
4. Connection strings contain database endpoints and are processed by CloudShell services
These limitations are important considerations for workloads with specific compliance or regulatory requirements.

View File

@@ -0,0 +1,96 @@
import { AbstractShellHandler, DISABLE_HISTORY, START_MARKER, EXIT_COMMAND } from "./AbstractShellHandler";
// Mock implementation for testing
class MockShellHandler extends AbstractShellHandler {
getShellName(): string {
return "MockShell";
}
getSetUpCommands(): string[] {
return ["setup-command-1", "setup-command-2"];
}
getConnectionCommand(): string {
return "mock-connection-command";
}
getEndpoint(): string {
return "mock-endpoint";
}
getTerminalSuppressedData(): string {
return "suppressed-data";
}
}
describe("AbstractShellHandler", () => {
let shellHandler: MockShellHandler;
// Reset all mocks and spies before each test
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
shellHandler = new MockShellHandler();
});
// Reset everything after all tests
afterAll(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.resetModules();
});
// Cleanup after each test
afterEach(() => {
jest.clearAllMocks();
});
describe("getInitialCommands", () => {
it("should combine commands in the correct order", () => {
// Spy on abstract methods to ensure they're called
const getSetUpCommandsSpy = jest.spyOn(shellHandler, "getSetUpCommands");
const getConnectionCommandSpy = jest.spyOn(shellHandler, "getConnectionCommand");
const result = shellHandler.getInitialCommands();
// Verify abstract methods were called
expect(getSetUpCommandsSpy).toHaveBeenCalled();
expect(getConnectionCommandSpy).toHaveBeenCalled();
// Verify output format and content
const expectedOutput = [
START_MARKER,
DISABLE_HISTORY,
"setup-command-1",
"setup-command-2",
`{ mock-connection-command; } || true;${EXIT_COMMAND}`,
]
.join("\n")
.concat("\n");
expect(result).toBe(expectedOutput);
});
});
describe("abstract methods implementation", () => {
it("should return the correct shell name", () => {
expect(shellHandler.getShellName()).toBe("MockShell");
});
it("should return the setup commands", () => {
expect(shellHandler.getSetUpCommands()).toEqual(["setup-command-1", "setup-command-2"]);
});
it("should return the connection command", () => {
expect(shellHandler.getConnectionCommand()).toBe("mock-connection-command");
});
it("should return the endpoint", () => {
expect(shellHandler.getEndpoint()).toBe("mock-endpoint");
});
it("should return the terminal suppressed data", () => {
expect(shellHandler.getTerminalSuppressedData()).toBe("suppressed-data");
});
});
});

View File

@@ -0,0 +1,91 @@
/**
* Command that serves as a marker to indicate the start of shell initialization.
* Outputs to /dev/null to prevent displaying in the terminal.
*/
export const START_MARKER = `echo "START INITIALIZATION" > /dev/null`;
/**
* Command to disable command history recording in the shell.
* Prevents initialization commands from appearing in history.
*/
export const DISABLE_HISTORY = `set +o history`;
/**
* Command that displays an error message and exits the shell session.
* Used when shell initialization or connection fails.
*/
export const EXIT_COMMAND = ` printf "\\033[1;31mSession ended. Please close this tab and initiate a new shell session if needed.\\033[0m\\n" && exit`;
/**
* Abstract class that defines the interface for shell-specific handlers
* in the CloudShell terminal implementation. Each supported shell type
* (Mongo, PG, etc.) should extend this class and implement
* the required methods.
*/
export abstract class AbstractShellHandler {
/**
* The name of the application using this shell handler.
* This is used for telemetry and logging purposes.
*/
protected APP_NAME = "CosmosExplorerTerminal";
abstract getShellName(): string;
abstract getSetUpCommands(): string[];
abstract getConnectionCommand(): string;
abstract getTerminalSuppressedData(): string;
/**
* Constructs the complete initialization command sequence for the shell.
*
* This method:
* 1. Starts with the initialization marker
* 2. Disables command history
* 3. Adds shell-specific setup commands
* 4. Adds the connection command with error handling
* 5. Adds a fallback exit command if connection fails
*
* The connection command is wrapped in a construct that prevents
* errors from terminating the entire session immediately, allowing
* the friendly exit message to be displayed.
*
* @returns {string} Complete initialization command sequence with newlines
*/
public getInitialCommands(): string {
const setupCommands = this.getSetUpCommands();
const connectionCommand = this.getConnectionCommand();
const allCommands = [
START_MARKER,
DISABLE_HISTORY,
...setupCommands,
`{ ${connectionCommand}; } || true;${EXIT_COMMAND}`,
];
return allCommands.join("\n").concat("\n");
}
/**
* Setup commands for MongoDB shell:
*
* 1. Check if mongosh is already installed
* 2. Download mongosh package if not installed
* 3. Extract the package to access mongosh binaries
* 4. Move extracted files to ~/mongosh directory
* 5. Add mongosh binary path to system PATH
* 6. Apply PATH changes by sourcing .bashrc
*
* Each command runs conditionally only if mongosh
* is not already present in the environment.
*/
protected mongoShellSetupCommands(): string[] {
const PACKAGE_VERSION: string = "2.5.0";
return [
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
`if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
`if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
`if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh/bin && mv mongosh-${PACKAGE_VERSION}-linux-x64/bin/mongosh ~/mongosh/bin/ && chmod +x ~/mongosh/bin/mongosh; fi`,
`if ! command -v mongosh &> /dev/null; then rm -rf mongosh-${PACKAGE_VERSION}-linux-x64 mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
"source ~/.bashrc",
];
}
}

View File

@@ -0,0 +1,148 @@
import * as CommonUtils from "../Utils/CommonUtils";
import { CassandraShellHandler } from "./CassandraShellHandler";
// Define interfaces for the database account structure
interface DatabaseAccountProperties {
cassandraEndpoint?: string;
}
interface DatabaseAccount {
name?: string;
properties?: DatabaseAccountProperties;
}
// Define mock state that can be modified by tests
const mockState = {
databaseAccount: {
name: "test-account",
properties: {
cassandraEndpoint: "https://test-endpoint.cassandra.cosmos.azure.com:443/",
},
} as DatabaseAccount,
};
// Mock dependencies using factory functions
jest.mock("../../../../UserContext", () => ({
get userContext() {
return {
get databaseAccount() {
return mockState.databaseAccount;
},
};
},
}));
// Reset all modules before running tests
beforeAll(() => {
jest.resetModules();
});
jest.mock("../Utils/CommonUtils", () => ({
getHostFromUrl: jest.fn().mockReturnValue("test-endpoint.cassandra.cosmos.azure.com"),
}));
describe("CassandraShellHandler", () => {
const testKey = "test-key";
let handler: CassandraShellHandler;
beforeEach(() => {
jest.clearAllMocks();
handler = new CassandraShellHandler(testKey);
// Reset mock state before each test
mockState.databaseAccount = {
name: "test-account",
properties: {
cassandraEndpoint: "https://test-endpoint.cassandra.cosmos.azure.com:443/",
},
};
});
// Clean up after all tests
afterAll(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.resetModules();
});
describe("Positive test cases", () => {
test("should return 'Cassandra' as shell name", () => {
expect(handler.getShellName()).toBe("Cassandra");
});
test("should return an array of setup commands", () => {
const commands = handler.getSetUpCommands();
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBe(5);
expect(commands).toContain("source ~/.bashrc");
expect(
commands.some((cmd) =>
cmd.includes("if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi"),
),
).toBe(true);
expect(commands.some((cmd) => cmd.includes("pip3 install --user cqlsh==6.2.0"))).toBe(true);
expect(commands.some((cmd) => cmd.includes("export SSL_VERSION=TLSv1_2"))).toBe(true);
expect(commands.some((cmd) => cmd.includes("export SSL_VALIDATE=false"))).toBe(true);
});
test("should return correct connection command", () => {
const expectedCommand = `cqlsh test-endpoint.cassandra.cosmos.azure.com 10350 -u test-account -p test-key --ssl`;
expect(handler.getConnectionCommand()).toBe(expectedCommand);
expect(CommonUtils.getHostFromUrl).toHaveBeenCalledWith("https://test-endpoint.cassandra.cosmos.azure.com:443/");
});
test("should return the correct terminal suppressed data", () => {
expect(handler.getTerminalSuppressedData()).toBe("");
});
test("should include the correct package version in setup commands", () => {
const commands = handler.getSetUpCommands();
const hasCorrectPackageVersion = commands.some((cmd) => cmd.includes("cqlsh==6.2.0"));
expect(hasCorrectPackageVersion).toBe(true);
});
});
describe("Negative test cases", () => {
test("should handle empty host from URL", () => {
(CommonUtils.getHostFromUrl as jest.Mock).mockReturnValueOnce("");
const command = handler.getConnectionCommand();
expect(command).toBe("cqlsh 10350 -u test-account -p test-key --ssl");
});
test("should handle empty key", () => {
const emptyKeyHandler = new CassandraShellHandler("");
expect(emptyKeyHandler.getConnectionCommand()).toBe(
"cqlsh test-endpoint.cassandra.cosmos.azure.com 10350 -u test-account -p --ssl",
);
});
test("should handle undefined account name", () => {
mockState.databaseAccount = {
properties: { cassandraEndpoint: "https://test-endpoint.cassandra.cosmos.azure.com:443/" },
};
expect(handler.getConnectionCommand()).toBe("echo 'Database name not found.'");
});
test("should handle undefined database account", () => {
mockState.databaseAccount = undefined;
expect(handler.getConnectionCommand()).toBe("echo 'Database name not found.'");
});
test("should handle missing cassandra endpoint", () => {
mockState.databaseAccount = {
name: "test-account",
properties: {},
};
expect(handler.getConnectionCommand()).toBe("echo 'Cassandra endpoint not found.'");
});
});
});

View File

@@ -0,0 +1,47 @@
import { userContext } from "../../../../UserContext";
import { getHostFromUrl } from "../Utils/CommonUtils";
import { AbstractShellHandler } from "./AbstractShellHandler";
const PACKAGE_VERSION: string = "6.2.0";
export class CassandraShellHandler extends AbstractShellHandler {
private _key: string;
private _endpoint: string | undefined;
constructor(private key: string) {
super();
this._key = key;
this._endpoint = userContext?.databaseAccount?.properties?.cassandraEndpoint;
}
public getShellName(): string {
return "Cassandra";
}
public getSetUpCommands(): string[] {
return [
"if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi",
`if ! command -v cqlsh &> /dev/null; then pip3 install --user cqlsh==${PACKAGE_VERSION} ; fi`,
"echo 'export SSL_VERSION=TLSv1_2' >> ~/.bashrc",
"echo 'export SSL_VALIDATE=false' >> ~/.bashrc",
"source ~/.bashrc",
];
}
public getConnectionCommand(): string {
if (!this._endpoint) {
return `echo '${this.getShellName()} endpoint not found.'`;
}
const dbName = userContext?.databaseAccount?.name;
if (!dbName) {
return "echo 'Database name not found.'";
}
return `cqlsh ${getHostFromUrl(this._endpoint)} 10350 -u ${dbName} -p ${this._key} --ssl`;
}
public getTerminalSuppressedData(): string {
return "";
}
}

View File

@@ -0,0 +1,130 @@
import { userContext } from "../../../../UserContext";
import * as CommonUtils from "../Utils/CommonUtils";
import { MongoShellHandler } from "./MongoShellHandler";
// Define interfaces for type safety
interface DatabaseAccountProperties {
mongoEndpoint?: string;
}
interface DatabaseAccount {
id?: string;
name: string;
location?: string;
type?: string;
kind?: string;
properties: DatabaseAccountProperties;
}
interface UserContextType {
databaseAccount: DatabaseAccount;
}
// Mock dependencies
jest.mock("../../../../UserContext", () => ({
userContext: {
databaseAccount: {
name: "test-account",
properties: {
mongoEndpoint: "https://test-mongo.documents.azure.com:443/",
},
},
},
}));
jest.mock("../Utils/CommonUtils", () => ({
getHostFromUrl: jest.fn().mockReturnValue("test-mongo.documents.azure.com"),
}));
describe("MongoShellHandler", () => {
const testKey = "test-key";
let mongoShellHandler: MongoShellHandler;
beforeEach(() => {
mongoShellHandler = new MongoShellHandler(testKey);
jest.clearAllMocks();
});
// Clean up after each test
afterEach(() => {
jest.clearAllMocks();
});
// Clean up after all tests
afterAll(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.resetModules();
});
describe("getShellName", () => {
it("should return MongoDB", () => {
expect(mongoShellHandler.getShellName()).toBe("MongoDB");
});
});
describe("getSetUpCommands", () => {
it("should return an array of setup commands", () => {
const commands = mongoShellHandler.getSetUpCommands();
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBe(7);
expect(commands[1]).toContain("mongosh-2.5.0-linux-x64.tgz");
});
});
describe("getConnectionCommand", () => {
it("should return the correct connection command", () => {
// Save original databaseAccount
const originalDatabaseAccount = userContext.databaseAccount;
// Directly assign the modified databaseAccount
(userContext as UserContextType).databaseAccount = {
id: "test-id",
name: "test-account",
location: "test-location",
type: "test-type",
kind: "test-kind",
properties: { mongoEndpoint: "https://test-mongo.documents.azure.com:443/" },
};
const command = mongoShellHandler.getConnectionCommand();
expect(command).toBe(
"mongosh mongodb://test-mongo.documents.azure.com:10255?appName=CosmosExplorerTerminal --username test-account --password test-key --tls --tlsAllowInvalidCertificates",
);
expect(CommonUtils.getHostFromUrl).toHaveBeenCalledWith("https://test-mongo.documents.azure.com:443/");
// Restore original
(userContext as UserContextType).databaseAccount = originalDatabaseAccount;
});
it("should handle missing database account name", () => {
// Save original databaseAccount
const originalDatabaseAccount = userContext.databaseAccount;
// Directly assign the modified databaseAccount
(userContext as UserContextType).databaseAccount = {
id: "test-id",
name: "", // Empty name to simulate missing name
location: "test-location",
type: "test-type",
kind: "test-kind",
properties: { mongoEndpoint: "https://test.com" },
};
const command = mongoShellHandler.getConnectionCommand();
expect(command).toBe("echo 'Database name not found.'");
// Restore original
(userContext as UserContextType).databaseAccount = originalDatabaseAccount;
});
});
describe("getTerminalSuppressedData", () => {
it("should return the correct warning message", () => {
expect(mongoShellHandler.getTerminalSuppressedData()).toBe("Warning: Non-Genuine MongoDB Detected");
});
});
});

View File

@@ -0,0 +1,47 @@
import { userContext } from "../../../../UserContext";
import { getHostFromUrl } from "../Utils/CommonUtils";
import { AbstractShellHandler } from "./AbstractShellHandler";
export class MongoShellHandler extends AbstractShellHandler {
private _key: string;
private _endpoint: string | undefined;
constructor(private key: string) {
super();
this._key = key;
this._endpoint = userContext?.databaseAccount?.properties?.mongoEndpoint;
}
public getShellName(): string {
return "MongoDB";
}
public getSetUpCommands(): string[] {
return this.mongoShellSetupCommands();
}
public getConnectionCommand(): string {
if (!this._endpoint) {
return `echo '${this.getShellName()} endpoint not found.'`;
}
const dbName = userContext?.databaseAccount?.name;
if (!dbName) {
return "echo 'Database name not found.'";
}
return (
"mongosh mongodb://" +
getHostFromUrl(this._endpoint) +
":10255?appName=" +
this.APP_NAME +
" --username " +
dbName +
" --password " +
this._key +
" --tls --tlsAllowInvalidCertificates"
);
}
public getTerminalSuppressedData(): string {
return "Warning: Non-Genuine MongoDB Detected";
}
}

View File

@@ -0,0 +1,64 @@
import { PostgresShellHandler } from "./PostgresShellHandler";
// Mock dependencies
jest.mock("../../../../UserContext", () => ({
userContext: {
databaseAccount: {
properties: {
postgresqlEndpoint: "test-postgres.postgres.database.azure.com",
},
},
postgresConnectionStrParams: {
adminLogin: "test-admin",
},
},
}));
describe("PostgresShellHandler", () => {
let postgresShellHandler: PostgresShellHandler;
beforeEach(() => {
postgresShellHandler = new PostgresShellHandler();
jest.clearAllMocks();
});
// Clean up after each test
afterEach(() => {
jest.clearAllMocks();
});
// Clean up after all tests
afterAll(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.resetModules();
});
// Positive test cases
describe("Positive Tests", () => {
it("should return correct shell name", () => {
expect(postgresShellHandler.getShellName()).toBe("PostgreSQL");
});
it("should return array of setup commands with correct package version", () => {
const commands = postgresShellHandler.getSetUpCommands();
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBe(9);
expect(commands[1]).toContain("postgresql-15.2.tar.bz2");
expect(commands[0]).toContain("psql not found");
});
it("should generate proper connection command with endpoint", () => {
const connectionCommand = postgresShellHandler.getConnectionCommand();
expect(connectionCommand).toContain('-h "test-postgres.postgres.database.azure.com"');
expect(connectionCommand).toContain("-p 5432");
expect(connectionCommand).toContain("--set=sslmode=require");
});
it("should return empty string for terminal suppressed data", () => {
expect(postgresShellHandler.getTerminalSuppressedData()).toBe("");
});
});
});

View File

@@ -0,0 +1,63 @@
import { userContext } from "../../../../UserContext";
import { AbstractShellHandler } from "./AbstractShellHandler";
const PACKAGE_VERSION: string = "15.2";
export class PostgresShellHandler extends AbstractShellHandler {
private _endpoint: string | undefined;
constructor() {
super();
this._endpoint = userContext?.databaseAccount?.properties?.postgresqlEndpoint;
}
public getShellName(): string {
return "PostgreSQL";
}
/**
* PostgreSQL setup commands for CloudShell:
*
* 1. Check if psql client is already installed
* 2. Download PostgreSQL source package if needed
* 3. Extract the PostgreSQL package
* 4. Create installation directory
* 5. Download and extract readline dependency
* 6. Configure readline with appropriate installation path
* 7. Add PostgreSQL binaries to system PATH
* 8. Apply PATH changes
*
* All installation steps run conditionally only if
* psql is not already available in the environment.
*/
public getSetUpCommands(): string[] {
return [
"if ! command -v psql &> /dev/null; then echo '⚠️ psql not found. Installing...'; fi",
`if ! command -v psql &> /dev/null; then curl -LO https://ftp.postgresql.org/pub/source/v${PACKAGE_VERSION}/postgresql-${PACKAGE_VERSION}.tar.bz2; fi`,
`if ! command -v psql &> /dev/null; then tar -xvjf postgresql-${PACKAGE_VERSION}.tar.bz2; fi`,
"if ! command -v psql &> /dev/null; then mkdir -p ~/pgsql; fi",
"if ! command -v psql &> /dev/null; then curl -LO https://ftp.gnu.org/gnu/readline/readline-8.1.tar.gz; fi",
"if ! command -v psql &> /dev/null; then tar -xvzf readline-8.1.tar.gz; fi",
"if ! command -v psql &> /dev/null; then cd readline-8.1 && ./configure --prefix=$HOME/pgsql; fi",
"if ! command -v psql &> /dev/null; then echo 'export PATH=$HOME/pgsql/bin:$PATH' >> ~/.bashrc; fi",
"source ~/.bashrc",
];
}
public getConnectionCommand(): string {
if (!this._endpoint) {
return `echo '${this.getShellName()} endpoint not found.'`;
}
// Database name is hardcoded as "citus" because Azure Cosmos DB for PostgreSQL
// uses Citus as its distributed database extension with this default database name.
// All Azure Cosmos DB PostgreSQL deployments follow this convention.
// Ref. https://learn.microsoft.com/en-us/azure/cosmos-db/postgresql/reference-limits#database-creation
const loginName = userContext.postgresConnectionStrParams.adminLogin;
return `psql -h "${this._endpoint}" -p 5432 -d "citus" -U "${loginName}" --set=sslmode=require --set=application_name=${this.APP_NAME}`;
}
public getTerminalSuppressedData(): string {
return "";
}
}

View File

@@ -0,0 +1,113 @@
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { CassandraShellHandler } from "./CassandraShellHandler";
import { MongoShellHandler } from "./MongoShellHandler";
import { PostgresShellHandler } from "./PostgresShellHandler";
import { getHandler, getKey } from "./ShellTypeFactory";
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
// Mock dependencies
jest.mock("../../../../UserContext", () => ({
userContext: {
databaseAccount: { name: "testDbName" },
subscriptionId: "testSubId",
resourceGroup: "testResourceGroup",
},
}));
jest.mock("../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
listKeys: jest.fn(),
}));
describe("ShellTypeHandlerFactory", () => {
const mockKey = "testKey";
beforeEach(() => {
(listKeys as jest.Mock).mockResolvedValue({ primaryMasterKey: mockKey });
});
afterEach(() => {
jest.clearAllMocks();
});
// Clean up after each test
afterEach(() => {
jest.clearAllMocks();
});
// Clean up after all tests
afterAll(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.resetModules();
});
// Negative test cases
describe("Negative test cases", () => {
it("should throw an error for unsupported terminal kind", async () => {
await expect(getHandler("UnsupportedKind" as unknown as TerminalKind)).rejects.toThrow(
"Unsupported shell type: UnsupportedKind",
);
});
it("should return empty string when database name is missing", async () => {
// Temporarily modify the mock
const originalName = userContext.databaseAccount.name;
type DatabaseAccountType = { name: string };
(userContext.databaseAccount as DatabaseAccountType).name = "";
const key = await getKey();
expect(key).toBe("");
expect(listKeys).not.toHaveBeenCalled();
// Restore the mock
(userContext.databaseAccount as DatabaseAccountType).name = originalName;
});
it("should return empty string when listKeys returns null", async () => {
(listKeys as jest.Mock).mockResolvedValue(null);
const key = await getKey();
expect(key).toBe("");
});
it("should return empty string when primaryMasterKey is missing", async () => {
(listKeys as jest.Mock).mockResolvedValue({
/* no primaryMasterKey */
});
const key = await getKey();
expect(key).toBe("");
});
});
// Positive test cases
describe("Positive test cases", () => {
it("should return PostgresShellHandler for Postgres terminal kind", async () => {
const handler = await getHandler(TerminalKind.Postgres);
expect(handler).toBeInstanceOf(PostgresShellHandler);
});
it("should return MongoShellHandler with key for Mongo terminal kind", async () => {
const handler = await getHandler(TerminalKind.Mongo);
expect(handler).toBeInstanceOf(MongoShellHandler);
});
it("should return VCoreMongoShellHandler for VCoreMongo terminal kind", async () => {
const handler = await getHandler(TerminalKind.VCoreMongo);
expect(handler).toBeInstanceOf(VCoreMongoShellHandler);
});
it("should return CassandraShellHandler with key for Cassandra terminal kind", async () => {
const handler = await getHandler(TerminalKind.Cassandra);
expect(handler).toBeInstanceOf(CassandraShellHandler);
});
it("should get key successfully when database name exists", async () => {
const key = await getKey();
expect(key).toBe(mockKey);
expect(listKeys).toHaveBeenCalledWith("testSubId", "testResourceGroup", "testDbName");
});
});
});

View File

@@ -0,0 +1,36 @@
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { AbstractShellHandler } from "./AbstractShellHandler";
import { CassandraShellHandler } from "./CassandraShellHandler";
import { MongoShellHandler } from "./MongoShellHandler";
import { PostgresShellHandler } from "./PostgresShellHandler";
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
/**
* Gets the appropriate handler for the given shell type
*/
export async function getHandler(shellType: TerminalKind): Promise<AbstractShellHandler> {
switch (shellType) {
case TerminalKind.Postgres:
return new PostgresShellHandler();
case TerminalKind.Mongo:
return new MongoShellHandler(await getKey());
case TerminalKind.VCoreMongo:
return new VCoreMongoShellHandler();
case TerminalKind.Cassandra:
return new CassandraShellHandler(await getKey());
default:
throw new Error(`Unsupported shell type: ${shellType}`);
}
}
export async function getKey(): Promise<string> {
const dbName = userContext.databaseAccount.name;
if (!dbName) {
return "";
}
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
return keys?.primaryMasterKey || "";
}

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