Compare commits

..

23 Commits

Author SHA1 Message Date
Sourabh Jain
a7e38201c4 refactored code 2025-04-07 21:26:02 +05:30
Sourabh Jain
80edb66fbf wip for private endpoint support 2025-04-04 22:41:45 +05:30
Sourabh Jain
c62e89228e updated code 2025-04-03 11:27:52 +05:30
Sourabh Jain
d68bf02a0e terminal log changes 2025-04-02 07:15:49 +05:30
Sourabh Jain
62e90ed26d vnet automation 2025-04-02 06:51:09 +05:30
Sourabh Jain
1f4e79f856 fix comands 2025-03-25 13:00:28 +05:30
Sourabh Jain
9db1edca03 fix vcore username 2025-03-25 08:04:22 +05:30
Sourabh Jain
8b4eaa95ea ui and functional changes 2025-03-25 00:24:17 +05:30
Sourabh Jain
10b0da2190 Vnet addition 2025-03-22 15:33:56 +05:30
Sourabh Jain
4313d6ecbd enable cloudshell with vnet config enabled 2025-03-18 22:47:31 +05:30
Sourabh Jain
83eafd4485 fix terminals 2025-03-18 17:13:49 +05:30
Sourabh Jain
44e85647e4 add other db support 2025-03-17 06:35:19 +05:30
Sourabh Jain
ec891671b6 code refactor 2025-02-28 07:55:48 +05:30
Sourabh Jain
942de980c3 code refactor 2025-02-25 12:09:31 +05:30
Sourabh Jain
2c3c4e7db7 added consent 2025-02-25 11:23:42 +05:30
Sourabh Jain
9b2cb8a1a9 refactor code and add casandra and mongo commands 2025-02-24 23:24:25 +05:30
Sourabh Jain
41439cc7d4 refactor code 2025-02-24 15:10:07 +05:30
Sourabh Jain
ce08ce05f2 mongo is working 2025-02-21 21:09:44 +05:30
Sourabh Jain
323276beff cloudshell api failed with client id 2025-02-19 07:00:41 +05:30
Sourabh Jain
1678ec0a23 xterm add 2025-02-18 17:11:39 +05:30
Sourabh Jain
0babb1fa13 reverted code 2025-02-18 07:57:30 +05:30
Sourabh Jain
78c8df0904 Not wroking code 2025-02-18 07:53:52 +05:30
Sourabh Jain
76742455bf first draft 2025-02-17 07:12:49 +05:30
157 changed files with 5896 additions and 41435 deletions

View File

@@ -11,9 +11,3 @@ pool:
steps:
- task: ComponentGovernanceComponentDetection@0
inputs:
scanType: 'Register'
verbosity: 'Verbose'
sourceScanPath: 'manifest'
detectorsFilter: 'cgmanifest'
alertWarningLevel: 'Low'

View File

@@ -61,8 +61,6 @@
@GalleryBackgroundColor: #fdfdfd;
@LinkColor: #2d6da4;
//Icons
@InfoIconColor: #0072c6;
@WarningIconColor: #db7500;
@@ -248,10 +246,6 @@
outline: 1px dashed @FocusColor;
}
.focusedBorder() {
border: 1px dashed @FocusColor;
}
/************************************************************************************************
Common Toggle Switch
*************************************************************************************************/

View File

@@ -1914,20 +1914,13 @@ 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

@@ -1,25 +0,0 @@
{
"$schema": "https://json.schemastore.org/component-detection-manifest.json",
"Registrations": [
{
"Component": {
"Type": "git",
"Git": {
"RepositoryUrl": "https://github.com/mongodb-js/mongosh",
"CommitHash": "6718ae4e76be007542087b8a674d7a77861c7d08"
}
},
"DevelopmentDependency": false
},
{
"Component": {
"Type": "git",
"Git": {
"RepositoryUrl": "https://github.com/jeffwidman/cqlsh",
"CommitHash": "dbefab4f3082bd3525e9e39d836734fd905fb8df"
}
},
"DevelopmentDependency": false
}
]
}

57
package-lock.json generated
View File

@@ -51,6 +51,7 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -86,7 +87,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "6.2.1",
"p-retry": "4.6.2",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
@@ -12662,9 +12663,7 @@
}
},
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"version": "0.12.0",
"license": "MIT"
},
"node_modules/@types/sanitize-html": {
@@ -13240,6 +13239,11 @@
"node": ">=10.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"
@@ -21801,18 +21805,6 @@
"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",
@@ -30257,20 +30249,14 @@
}
},
"node_modules/p-retry": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
"version": "4.6.2",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.2",
"is-network-error": "^1.0.0",
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"node": ">=8"
}
},
"node_modules/p-try": {
@@ -36017,13 +36003,6 @@
}
}
},
"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,
@@ -36071,20 +36050,6 @@
"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

@@ -13,8 +13,8 @@
"@babel/plugin-proposal-decorators": "7.12.12",
"@fluentui/react": "8.119.0",
"@fluentui/react-components": "9.54.2",
"@jupyterlab/services": "6.0.2",
"@jupyterlab/terminal": "3.0.3",
"@jupyterlab/services": "6.0.2",
"@microsoft/applicationinsights-web": "2.6.1",
"@nteract/commutable": "7.5.1",
"@nteract/connected-components": "6.8.2",
@@ -46,6 +46,7 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -81,7 +82,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "6.2.1",
"p-retry": "4.6.2",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -28,12 +29,7 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",

37051
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -530,13 +530,6 @@ export class ariaLabelForLearnMoreLink {
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
}
export class GlobalSecondaryIndexLabels {
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
}
export class FeedbackLabels {
public static readonly provideFeedback: string = "Provide feedback";
}
export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
export const QueryCopilotSampleContainerId = "SampleContainer";

View File

@@ -1,15 +1,13 @@
import * as Cosmos from "@azure/cosmos";
import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizationTokenUsingResourceTokens";
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext";
import { FabricArtifactInfo, updateUserContext, userContext } from "../UserContext";
import { isDataplaneRbacSupported } from "../Utils/APITypeUtils";
import { updateUserContext, userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
@@ -20,7 +18,7 @@ const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo;
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && isDataplaneRbacSupported(userContext.apiType);
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && userContext.apiType === "SQL";
if (userContext.features.enableAadDataPlane || dataPlaneRBACOptionEnabled) {
Logger.logInfo(
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
@@ -43,7 +41,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
return decodeURIComponent(headers.authorization);
}
if (isFabricMirroredKey()) {
if (configContext.platform === Platform.Fabric) {
switch (requestInfo.resourceType) {
case Cosmos.ResourceType.conflicts:
case Cosmos.ResourceType.container:
@@ -55,13 +53,8 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
// User resource tokens
// TODO userContext.fabricContext.databaseConnectionInfo can be undefined
headers[HttpHeaders.msDate] = new Date().toUTCString();
const resourceTokens = (
userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]
).resourceTokenInfo.resourceTokens;
checkDatabaseResourceTokensValidity(
(userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY])
.resourceTokenInfo.resourceTokensTimestamp,
);
const resourceTokens = userContext.fabricContext.databaseConnectionInfo.resourceTokens;
checkDatabaseResourceTokensValidity(userContext.fabricContext.databaseConnectionInfo.resourceTokensTimestamp);
return getAuthorizationTokenUsingResourceTokens(resourceTokens, requestInfo.path, requestInfo.resourceId);
case Cosmos.ResourceType.none:
@@ -72,9 +65,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
// For now, these operations aren't used, so fetching the authorization token is commented out.
// This provider must return a real token to pass validation by the client, so we return the cached resource token
// (which is a valid token, but won't work for these operations).
const resourceTokens2 = (
userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]
).resourceTokenInfo.resourceTokens;
const resourceTokens2 = userContext.fabricContext.databaseConnectionInfo.resourceTokens;
return getAuthorizationTokenUsingResourceTokens(resourceTokens2, requestInfo.path, requestInfo.resourceId);
/* ************** TODO: Uncomment this code if we need to support these operations **************
@@ -125,11 +116,7 @@ export const endpoint = () => {
const location = _global.parent ? _global.parent.location : _global.location;
return configContext.EMULATOR_ENDPOINT || location.origin;
}
return (
userContext.selectedRegionalEndpoint ||
userContext.endpoint ||
userContext?.databaseAccount?.properties?.documentEndpoint
);
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint;
};
export async function getTokenFromAuthService(
@@ -207,7 +194,6 @@ 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,6 +1,5 @@
import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() {
@@ -27,9 +26,3 @@ export function getWorkloadType(): WorkloadType {
}
return workloadType;
}
export function isGlobalSecondaryIndexEnabled(): boolean {
return (
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
);
}

View File

@@ -1,8 +1,5 @@
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
@@ -24,9 +21,7 @@ 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,10 +1,10 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "../ConfigContext";
// eslint-disable-next-line @typescript-eslint/no-var-requires
export const StyleConstants = require("less-vars-loader!../../less/Common/Constants.less");
export function updateStyles(): void {
if (isFabric()) {
if (configContext.platform === Platform.Fabric) {
StyleConstants.AccentMediumHigh = StyleConstants.FabricAccentMediumHigh;
StyleConstants.AccentMedium = StyleConstants.FabricAccentMedium;
StyleConstants.AccentLight = StyleConstants.FabricAccentLight;

View File

@@ -1,5 +1,4 @@
import { ContainerRequest, ContainerResponse, DatabaseRequest, DatabaseResponse, RequestOptions } from "@azure/cosmos";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { useDatabases } from "../../Explorer/useDatabases";
@@ -25,7 +24,7 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
);
try {
let collection: DataModels.Collection;
if (!isFabricNative() && userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
if (params.createNewDatabase) {
const createDatabaseParams: DataModels.CreateDatabaseParams = {
autoPilotMaxThroughput: params.autoPilotMaxThroughput,

View File

@@ -1,74 +0,0 @@
import { constructRpOptions } from "Common/dataAccess/createCollection";
import { handleError } from "Common/ErrorHandlingUtils";
import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources";
import {
CreateUpdateOptions,
SqlContainerResource,
SqlDatabaseCreateUpdateParameters,
} from "Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils";
export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise<Collection> => {
const clearMessage = logConsoleProgress(
`Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`,
);
const options: CreateUpdateOptions = constructRpOptions(params);
const resource: SqlContainerResource = {
id: params.materializedViewId,
};
if (params.materializedViewDefinition) {
resource.materializedViewDefinition = params.materializedViewDefinition;
}
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
if (params.indexingPolicy) {
resource.indexingPolicy = params.indexingPolicy;
}
if (params.partitionKey) {
resource.partitionKey = params.partitionKey;
}
if (params.uniqueKeyPolicy) {
resource.uniqueKeyPolicy = params.uniqueKeyPolicy;
}
if (params.vectorEmbeddingPolicy) {
resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy;
}
if (params.fullTextPolicy) {
resource.fullTextPolicy = params.fullTextPolicy;
}
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource,
options,
},
};
try {
const createResponse = await createUpdateSqlContainer(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
params.materializedViewId,
rpPayload,
);
logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`);
return createResponse && (createResponse.properties.resource as Collection);
} catch (error) {
handleError(
error,
"CreateGlobalSecondaryIndex",
`Error while creating global secondary index ${params.materializedViewId}`,
);
throw error;
} finally {
clearMessage();
}
};

View File

@@ -1,4 +1,3 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { userContext } from "../../UserContext";
import { deleteCassandraTable } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
@@ -13,7 +12,7 @@ import { handleError } from "../ErrorHandlingUtils";
export async function deleteCollection(databaseId: string, collectionId: string): Promise<void> {
const clearMessage = logConsoleProgress(`Deleting container ${collectionId}`);
try {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && !isFabric()) {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
await deleteCollectionWithARM(databaseId, collectionId);
} else {
await client().database(databaseId).container(collectionId).delete();

View File

@@ -1,4 +1,3 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -14,11 +13,6 @@ 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

@@ -1,10 +1,9 @@
import { ContainerResponse } from "@azure/cosmos";
import { Queries } from "Common/Constants";
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { isFabric, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { FabricArtifactInfo, userContext } from "../../UserContext";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraTables } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinGraphs } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
@@ -17,13 +16,15 @@ import { handleError } from "../ErrorHandlingUtils";
export async function readCollections(databaseId: string): Promise<DataModels.Collection[]> {
const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`);
if (isFabricMirroredKey() && userContext.fabricContext?.databaseName === databaseId) {
if (
configContext.platform === Platform.Fabric &&
userContext.fabricContext &&
userContext.fabricContext.databaseConnectionInfo.databaseId === databaseId
) {
const collections: DataModels.Collection[] = [];
const promises: Promise<ContainerResponse>[] = [];
for (const collectionResourceId in (
userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]
).resourceTokenInfo.resourceTokens) {
for (const collectionResourceId in userContext.fabricContext.databaseConnectionInfo.resourceTokens) {
// Dictionary key looks like this: dbs/SampleDB/colls/Container
const resourceIdObj = collectionResourceId.split("/");
const tokenDatabaseId = resourceIdObj[1];
@@ -55,8 +56,7 @@ export async function readCollections(databaseId: string): Promise<DataModels.Co
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
return await readCollectionsWithARM(databaseId);
}
@@ -126,12 +126,5 @@ async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Co
throw new Error(`Unsupported default experience type: ${apiType}`);
}
// TO DO: Remove when we get RP API Spec with materializedViews
/* eslint-disable @typescript-eslint/no-explicit-any */
return rpResponse?.value?.map((collection: any) => {
const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection;
collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews;
collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition;
return collectionDataModel;
});
return rpResponse?.value?.map((collection) => collection.properties?.resource as DataModels.Collection);
}

View File

@@ -1,4 +1,4 @@
import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import { Offer, ReadDatabaseOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -11,9 +11,8 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readDatabaseOffer = async (params: ReadDatabaseOfferParams): Promise<Offer> => {
if (isFabricMirroredKey() || isFabricNative()) {
// For Fabric Mirroring, it is slow, because it requests the token and we don't need it.
// For Fabric Native, it is not supported.
if (configContext.platform === Platform.Fabric) {
// TODO This works, but is very slow, because it requests the token, so we skip for now
console.error("Skiping readDatabaseOffer for Fabric");
return undefined;
}
@@ -24,8 +23,7 @@ export const readDatabaseOffer = async (params: ReadDatabaseOfferParams): Promis
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
return await readDatabaseOfferWithARM(params.databaseId);
}

View File

@@ -1,8 +1,7 @@
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { FabricArtifactInfo, userContext } from "../../UserContext";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
@@ -15,13 +14,8 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
let databases: DataModels.Database[];
const clearMessage = logConsoleProgress(`Querying databases`);
if (
isFabricMirroredKey() &&
(userContext.fabricContext?.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]).resourceTokenInfo
.resourceTokens
) {
const tokensData = (userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY])
.resourceTokenInfo;
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.databaseConnectionInfo.resourceTokens) {
const tokensData = userContext.fabricContext.databaseConnectionInfo;
const databaseIdsSet = new Set<string>(); // databaseId
@@ -52,28 +46,13 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
}));
clearMessage();
return databases;
} else if (isFabricNative() && userContext.fabricContext?.databaseName) {
const databaseId = userContext.fabricContext.databaseName;
databases = [
{
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: databaseId,
collections: [],
},
];
clearMessage();
return databases;
}
try {
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
databases = await readDatabasesWithARM();
} else {

View File

@@ -1,5 +1,4 @@
import { ContainerDefinition, RequestOptions } from "@azure/cosmos";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Collection } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -37,8 +36,7 @@ export async function updateCollection(
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
collection = await updateCollectionWithARM(databaseId, collectionId, newCollection);
} else {

View File

@@ -1,5 +1,4 @@
import { OfferDefinition, RequestOptions } from "@azure/cosmos";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Offer, SDKOfferDefinition, ThroughputBucket, UpdateOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -57,7 +56,7 @@ export const updateOffer = async (params: UpdateOfferParams): Promise<Offer> =>
const clearMessage = logConsoleProgress(`Updating offer for ${offerResourceText}`);
try {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && !isFabric()) {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
if (params.collectionId) {
updatedOffer = await updateCollectionOfferWithARM(params);
} else if (userContext.apiType === "Tables") {

View File

@@ -32,7 +32,6 @@ export interface DatabaseAccountExtendedProperties {
writeLocations?: DatabaseAccountResponseLocation[];
enableFreeTier?: boolean;
enableAnalyticalStorage?: boolean;
enableMaterializedViews?: boolean;
isVirtualNetworkFilterEnabled?: boolean;
ipRules?: IpRule[];
privateEndpointConnections?: unknown[];
@@ -43,6 +42,11 @@ export interface DatabaseAccountExtendedProperties {
publicNetworkAccess?: string;
enablePriorityBasedExecution?: boolean;
vcoreMongoEndpoint?: string;
virtualNetworkRules?: VNetRule[];
}
export interface VNetRule {
id: string;
}
export interface DatabaseAccountResponseLocation {
@@ -165,8 +169,6 @@ export interface Collection extends Resource {
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
materializedViews?: MaterializedView[];
materializedViewDefinition?: MaterializedViewDefinition;
}
export interface CollectionsWithPagination {
@@ -226,17 +228,6 @@ export interface ComputedProperty {
export type ComputedProperties = ComputedProperty[];
export interface MaterializedView {
id: string;
_rid: string;
}
export interface MaterializedViewDefinition {
definition: string;
sourceCollectionId: string;
sourceCollectionRid?: string;
}
export interface PartitionKey {
paths: string[];
kind: "Hash" | "Range" | "MultiHash";
@@ -359,7 +350,9 @@ export interface CreateDatabaseParams {
offerThroughput?: number;
}
export interface CreateCollectionParamsBase {
export interface CreateCollectionParams {
createNewDatabase: boolean;
collectionId: string;
databaseId: string;
databaseLevelThroughput: boolean;
offerThroughput?: number;
@@ -373,16 +366,6 @@ export interface CreateCollectionParamsBase {
fullTextPolicy?: FullTextPolicy;
}
export interface CreateCollectionParams extends CreateCollectionParamsBase {
createNewDatabase: boolean;
collectionId: string;
}
export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase {
materializedViewId: string;
materializedViewDefinition: MaterializedViewDefinition;
}
export interface VectorEmbeddingPolicy {
vectorEmbeddings: VectorEmbedding[];
}

View File

@@ -4,7 +4,6 @@
export enum FabricMessageTypes {
GetAuthorizationToken = "GetAuthorizationToken",
GetAllResourceTokens = "GetAllResourceTokens",
GetAccessToken = "GetAccessToken",
Ready = "Ready",
}

View File

@@ -1,9 +1,47 @@
import { AuthorizationToken } from "./FabricMessageTypes";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
// This is the version of these messages
export const FABRIC_RPC_VERSION = "FabricMessageV3";
export const FABRIC_RPC_VERSION = "2";
// Fabric to Data Explorer
// TODO Deprecated. Remove this section once DE is updated
export type FabricMessageV1 =
| {
type: "newContainer";
databaseName: string;
}
| {
type: "initialize";
message: {
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
error: string | undefined;
};
}
| {
type: "authorizationToken";
message: {
id: string;
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens";
message: {
id: string;
error: string | undefined;
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
};
};
// -----------------------------
export type FabricMessageV2 =
| {
type: "newContainer";
@@ -31,7 +69,7 @@ export type FabricMessageV2 =
message: {
id: string;
error: string | undefined;
data: ResourceTokenInfo | undefined;
data: FabricDatabaseConnectionInfo | undefined;
};
}
| {
@@ -41,88 +79,17 @@ export type FabricMessageV2 =
};
};
export type FabricMessageV3 =
| {
type: "newContainer";
databaseName: string;
}
| {
type: "initialize";
version: string;
id: string;
message: InitializeMessageV3<CosmosDbArtifactType>;
}
| {
type: "authorizationToken";
message: {
id: string;
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens_v2";
message: {
id: string;
error: string | undefined;
data: ResourceTokenInfo | undefined;
};
}
| {
type: "explorerVisible";
message: {
visible: boolean;
};
}
| {
type: "accessToken";
message: {
id: string;
error: string | undefined;
data: { accessToken: string };
};
}
| {
type: "refreshResourceTree";
message: {
id: string;
error: string | undefined;
};
};
export type CosmosDBTokenResponse = {
token: string;
date: string;
};
export enum CosmosDbArtifactType {
MIRRORED_KEY = "MIRRORED_KEY",
MIRRORED_AAD = "MIRRORED_AAD",
NATIVE = "NATIVE",
}
export interface ArtifactConnectionInfo {
[CosmosDbArtifactType.MIRRORED_KEY]: { connectionId: string };
[CosmosDbArtifactType.MIRRORED_AAD]: AccessTokenConnectionInfo;
[CosmosDbArtifactType.NATIVE]: AccessTokenConnectionInfo;
}
export interface AccessTokenConnectionInfo {
accessToken: string;
databaseName: string;
accountEndpoint: string;
}
export interface InitializeMessageV3<T extends CosmosDbArtifactType> {
connectionId: string;
isVisible: boolean;
isReadOnly: boolean;
artifactType: T;
artifactConnectionInfo: ArtifactConnectionInfo[T];
}
export interface CosmosDBConnectionInfoResponse {
export type CosmosDBConnectionInfoResponse = {
endpoint: string;
databaseId: string;
resourceTokens: Record<string, string> | undefined;
accessToken: string | undefined;
isReadOnly: boolean;
credentialType: "Key" | "OAuth2" | undefined;
}
resourceTokens: { [resourceId: string]: string };
};
export interface ResourceTokenInfo extends CosmosDBConnectionInfoResponse {
export interface FabricDatabaseConnectionInfo extends CosmosDBConnectionInfoResponse {
resourceTokensTimestamp: number;
}

View File

@@ -1,5 +1,4 @@
import {
JSONObject,
QueryMetrics,
Resource,
StoredProcedureDefinition,
@@ -144,8 +143,6 @@ export interface Collection extends CollectionBase {
geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
documentIds: ko.ObservableArray<DocumentId>;
computedProperties: ko.Observable<DataModels.ComputedProperties>;
materializedViews: ko.Observable<DataModels.MaterializedView[]>;
materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
cassandraKeys: CassandraTableKeys;
cassandraSchema: CassandraTableKey[];
@@ -207,12 +204,6 @@ 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,13 +1,5 @@
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext";
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
@@ -27,6 +19,7 @@ import * as ViewModels from "../Contracts/ViewModels";
import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel";
import { Platform, configContext } from "./../ConfigContext";
import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";
@@ -48,7 +41,7 @@ export interface DatabaseContextMenuButtonParams {
* New resource tree (in ReactJS)
*/
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
if (isFabric() && userContext.fabricContext?.isReadOnly) {
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
return undefined;
}
@@ -60,7 +53,7 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
},
];
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
if (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations) {
items.push({
iconSrc: DeleteDatabaseIcon,
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
@@ -103,17 +96,17 @@ 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: () => {
@@ -152,7 +145,7 @@ export const createCollectionContextMenuButton = (
});
}
if (!isFabric() || (isFabric() && !userContext.fabricContext?.isReadOnly)) {
if (configContext.platform !== Platform.Fabric) {
items.push({
iconSrc: DeleteCollectionIcon,
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
@@ -170,24 +163,6 @@ export const createCollectionContextMenuButton = (
});
}
if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) {
items.push({
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
onClick: () => {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer: container,
sourceContainer: selectedCollection,
};
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
);
},
});
}
return items;
};

View File

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

View File

@@ -12,7 +12,6 @@ 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";
@@ -45,10 +44,6 @@ import {
ConflictResolutionComponent,
ConflictResolutionComponentProps,
} from "./SettingsSubComponents/ConflictResolutionComponent";
import {
GlobalSecondaryIndexComponent,
GlobalSecondaryIndexComponentProps,
} from "./SettingsSubComponents/GlobalSecondaryIndexComponent";
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
import {
MongoIndexingPolicyComponent,
@@ -167,7 +162,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private shouldShowComputedPropertiesEditor: boolean;
private shouldShowIndexingPolicyEditor: boolean;
private shouldShowPartitionKeyEditor: boolean;
private isGlobalSecondaryIndex: boolean;
private isVectorSearchEnabled: boolean;
private isFullTextSearchEnabled: boolean;
private totalThroughputUsed: number;
@@ -185,8 +179,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL";
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
this.isGlobalSecondaryIndex =
!!this.collection?.materializedViewDefinition() || !!this.collection?.materializedViews();
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
@@ -1278,12 +1270,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId),
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
isReadOnly: isFabricNative(),
};
const globalSecondaryIndexComponentProps: GlobalSecondaryIndexComponentProps = {
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
};
const tabs: SettingsV2TabInfo[] = [];
@@ -1349,13 +1335,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
if (this.isGlobalSecondaryIndex) {
tabs.push({
tab: SettingsV2TabTypes.GlobalSecondaryIndexTab,
content: <GlobalSecondaryIndexComponent {...globalSecondaryIndexComponentProps} />,
});
}
const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange,
selectedKey: SettingsV2TabTypes[this.state.selectedTab],

View File

@@ -1,46 +0,0 @@
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

@@ -1,41 +0,0 @@
import { FontIcon, Link, Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
export interface GlobalSecondaryIndexComponentProps {
collection: ViewModels.Collection;
explorer: Explorer;
}
export const GlobalSecondaryIndexComponent: React.FC<GlobalSecondaryIndexComponentProps> = ({
collection,
explorer,
}) => {
const isTargetContainer = !!collection?.materializedViewDefinition();
const isSourceContainer = !!collection?.materializedViews();
return (
<Stack tokens={{ childrenGap: 8 }} styles={{ root: { maxWidth: 600 } }}>
<Stack horizontal verticalAlign="center" wrap tokens={{ childrenGap: 8 }}>
{isSourceContainer && (
<Text styles={{ root: { fontWeight: 600 } }}>This container has the following indexes defined for it.</Text>
)}
<Text>
<Link
target="_blank"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
>
Learn more
<FontIcon iconName="NavigateExternalInline" style={{ marginLeft: "4px" }} />
</Link>{" "}
about how to define global secondary indexes and how to use them.
</Text>
</Stack>
{isSourceContainer && <GlobalSecondaryIndexSourceComponent collection={collection} explorer={explorer} />}
{isTargetContainer && <GlobalSecondaryIndexTargetComponent collection={collection} />}
</Stack>
);
};

View File

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

View File

@@ -1,114 +0,0 @@
import { PrimaryButton } from "@fluentui/react";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { MaterializedView } from "Contracts/DataModels";
import Explorer from "Explorer/Explorer";
import { loadMonaco } from "Explorer/LazyMonaco";
import { AddGlobalSecondaryIndexPanel } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { useSidePanel } from "hooks/useSidePanel";
import * as monaco from "monaco-editor";
import React, { useEffect, useRef } from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
export interface GlobalSecondaryIndexSourceComponentProps {
collection: ViewModels.Collection;
explorer: Explorer;
}
export const GlobalSecondaryIndexSourceComponent: React.FC<GlobalSecondaryIndexSourceComponentProps> = ({
collection,
explorer,
}) => {
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(null);
const globalSecondaryIndexes: MaterializedView[] = collection?.materializedViews() ?? [];
// Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedView[] with collection id.
const getViewDetails = (viewId: string): { definition: string; partitionKey: string[] } => {
let definition = "";
let partitionKey: string[] = [];
useDatabases.getState().databases.find((database) => {
const collection = database.collections().find((collection) => collection.id() === viewId);
if (collection) {
const globalSecondaryIndexDefinition = collection.materializedViewDefinition();
globalSecondaryIndexDefinition && (definition = globalSecondaryIndexDefinition.definition);
collection.partitionKey?.paths && (partitionKey = collection.partitionKey.paths);
}
});
return { definition, partitionKey };
};
//JSON value for the editor using the fetched id and definitions.
const jsonValue = JSON.stringify(
globalSecondaryIndexes.map((view) => {
const { definition, partitionKey } = getViewDetails(view.id);
return {
name: view.id,
partitionKey: partitionKey.join(", "),
definition,
};
}),
null,
2,
);
// Initialize Monaco editor with the computed JSON value.
useEffect(() => {
let disposed = false;
const initMonaco = async () => {
const monacoInstance = await loadMonaco();
if (disposed || !editorContainerRef.current) {
return;
}
editorRef.current = monacoInstance.editor.create(editorContainerRef.current, {
value: jsonValue,
language: "json",
ariaLabel: "Global Secondary Index JSON",
readOnly: true,
});
};
initMonaco();
return () => {
disposed = true;
editorRef.current?.dispose();
};
}, [jsonValue]);
// Update the editor when the jsonValue changes.
useEffect(() => {
if (editorRef.current) {
editorRef.current.setValue(jsonValue);
}
}, [jsonValue]);
return (
<div>
<div
ref={editorContainerRef}
style={{
height: 250,
border: "1px solid #ccc",
borderRadius: 4,
overflow: "hidden",
}}
/>
<PrimaryButton
text="Add index"
styles={{ root: { width: "fit-content", marginTop: 12 } }}
onClick={() =>
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel explorer={explorer} sourceContainer={collection} />,
)
}
/>
</div>
);
};

View File

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

View File

@@ -1,45 +0,0 @@
import { Stack, Text } from "@fluentui/react";
import * as React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
export interface GlobalSecondaryIndexTargetComponentProps {
collection: ViewModels.Collection;
}
export const GlobalSecondaryIndexTargetComponent: React.FC<GlobalSecondaryIndexTargetComponentProps> = ({
collection,
}) => {
const globalSecondaryIndexDefinition = collection?.materializedViewDefinition();
const textHeadingStyle = {
root: { fontWeight: "600", fontSize: 16 },
};
const valueBoxStyle = {
root: {
backgroundColor: "#f3f3f3",
padding: "5px 10px",
borderRadius: "4px",
},
};
return (
<Stack tokens={{ childrenGap: 15 }} styles={{ root: { maxWidth: 600 } }}>
<Text styles={textHeadingStyle}>Global Secondary Index Settings</Text>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={{ root: { fontWeight: "600" } }}>Source container</Text>
<Stack styles={valueBoxStyle}>
<Text>{globalSecondaryIndexDefinition?.sourceCollectionId}</Text>
</Stack>
</Stack>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={{ root: { fontWeight: "600" } }}>Global secondary index definition</Text>
<Stack styles={valueBoxStyle}>
<Text>{globalSecondaryIndexDefinition?.definition}</Text>
</Stack>
</Stack>
</Stack>
);
};

View File

@@ -29,26 +29,16 @@ 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,
isReadOnly,
}) => {
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ database, collection, explorer }) => {
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();
@@ -173,61 +163,56 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
</Stack>
</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>
)}
</>
<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

@@ -57,7 +57,6 @@ export enum SettingsV2TabTypes {
ComputedPropertiesTab,
ContainerVectorPolicyTab,
ThroughputBucketsTab,
GlobalSecondaryIndexTab,
}
export enum ContainerPolicyTabTypes {
@@ -172,8 +171,6 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
return "Container Policies";
case SettingsV2TabTypes.ThroughputBucketsTab:
return "Throughput Buckets";
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
return "Global Secondary Index (Preview)";
default:
throw new Error(`Unknown tab ${tab}`);
}

View File

@@ -48,15 +48,6 @@ export const collection = {
]),
vectorEmbeddingPolicy: ko.observable<DataModels.VectorEmbeddingPolicy>({} as DataModels.VectorEmbeddingPolicy),
fullTextPolicy: ko.observable<DataModels.FullTextPolicy>({} as DataModels.FullTextPolicy),
materializedViews: ko.observable<DataModels.MaterializedView[]>([
{ id: "view1", _rid: "rid1" },
{ id: "view2", _rid: "rid2" },
]),
materializedViewDefinition: ko.observable<DataModels.MaterializedViewDefinition>({
definition: "SELECT * FROM c WHERE c.id = 1",
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
}),
readSettings: () => {
return;
},

View File

@@ -60,8 +60,6 @@ exports[`SettingsComponent renders 1`] = `
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
@@ -141,8 +139,6 @@ exports[`SettingsComponent renders 1`] = `
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
@@ -262,8 +258,6 @@ exports[`SettingsComponent renders 1`] = `
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
@@ -306,7 +300,6 @@ exports[`SettingsComponent renders 1`] = `
},
}
}
isReadOnly={false}
/>
</PivotItem>
<PivotItem
@@ -343,101 +336,6 @@ exports[`SettingsComponent renders 1`] = `
shouldDiscardComputedProperties={false}
/>
</PivotItem>
<PivotItem
headerText="Global Secondary Index (Preview)"
itemKey="GlobalSecondaryIndexTab"
key="GlobalSecondaryIndexTab"
style={
{
"marginTop": 20,
}
}
>
<GlobalSecondaryIndexComponent
collection={
{
"analyticalStorageTtl": [Function],
"changeFeedPolicy": [Function],
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
"paths": [],
"version": 2,
},
"partitionKeyProperties": [
"partitionKey",
],
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
/>
</PivotItem>
</StyledPivot>
</div>
</div>

View File

@@ -35,20 +35,12 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
setIsThroughputCapExceeded,
onCostAcknowledgeChange,
}: ThroughputInputProps) => {
let defaultThroughput: number;
const workloadType: Constants.WorkloadType = getWorkloadType();
if (
const defaultThroughput: number =
isFreeTier ||
isQuickstart ||
[Constants.WorkloadType.Learning, Constants.WorkloadType.DevelopmentTesting].includes(workloadType)
) {
defaultThroughput = AutoPilotUtils.autoPilotThroughput1K;
} else if (workloadType === Constants.WorkloadType.Production) {
defaultThroughput = AutoPilotUtils.autoPilotThroughput10K;
} else {
defaultThroughput = AutoPilotUtils.autoPilotThroughput4K;
}
[Constants.WorkloadType.Learning, Constants.WorkloadType.DevelopmentTesting].includes(getWorkloadType())
? AutoPilotUtils.autoPilotThroughput1K
: AutoPilotUtils.autoPilotThroughput4K;
const [isAutoscaleSelected, setIsAutoScaleSelected] = useState<boolean>(true);
const [throughput, setThroughput] = useState<number>(defaultThroughput);

View File

@@ -6,7 +6,6 @@ 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

@@ -8,7 +8,7 @@ import { MessageTypes } from "Contracts/ExplorerContracts";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { IGalleryItem } from "Juno/JunoClient";
import { isFabricMirrored, isFabricMirroredKey, scheduleRefreshFabricToken } from "Platform/Fabric/FabricUtil";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
@@ -43,7 +43,7 @@ import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
import { useSidePanel } from "../hooks/useSidePanel";
import { ReactTabKind, useTabs } from "../hooks/useTabs";
import { useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer";
import { DialogProps, useDialog } from "./Controls/Dialog";
import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent";
@@ -55,7 +55,7 @@ import type NotebookManager from "./Notebook/NotebookManager";
import { NotebookPaneContent } from "./Notebook/NotebookManager";
import { NotebookUtil } from "./Notebook/NotebookUtil";
import { useNotebook } from "./Notebook/useNotebook";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
import { StringInputPane } from "./Panes/StringInputPane/StringInputPane";
@@ -187,10 +187,6 @@ export default class Explorer {
useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath);
}
if (isFabricMirrored()) {
useTabs.getState().closeReactTab(ReactTabKind.Home);
}
this.refreshExplorer();
}
@@ -351,8 +347,8 @@ export default class Explorer {
};
public onRefreshResourcesClick = async (): Promise<void> => {
if (isFabricMirroredKey()) {
scheduleRefreshFabricToken(true).then(() => this.refreshAllDatabases());
if (configContext.platform === Platform.Fabric) {
scheduleRefreshDatabaseResourceToken(true).then(() => this.refreshAllDatabases());
return;
}
@@ -910,25 +906,28 @@ export default class Explorer {
}
public async openNotebookTerminal(kind: ViewModels.TerminalKind): Promise<void> {
if (useNotebook.getState().isPhoenixFeatures) {
await this.allocateContainer(PoolIdType.DefaultPoolId);
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) {
this.connectToNotebookTerminal(kind);
} else {
useDialog
.getState()
.showOkModalDialog(
"Failed to connect",
"Failed to connect to temporary workspace. This could happen because of network issues. Please refresh the page and try again.",
);
}
if (userContext.features.enableCloudShell || !useNotebook.getState().isPhoenixFeatures) {
this.connectToTerminal(kind);
return;
}
await this.allocateContainer(PoolIdType.DefaultPoolId);
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (notebookServerInfo?.notebookServerEndpoint) {
this.connectToTerminal(kind);
} else {
this.connectToNotebookTerminal(kind);
useDialog
.getState()
.showOkModalDialog(
"Failed to connect",
"Failed to connect to temporary workspace. This could happen because of network issues. Please refresh the page and try again."
);
}
}
private connectToNotebookTerminal(kind: ViewModels.TerminalKind): void {
private connectToTerminal(kind: ViewModels.TerminalKind): void {
let title: string;
switch (kind) {

View File

@@ -14,6 +14,10 @@
.flex-direction(@direction: row);
padding: 4px 5px;
label {
padding: 0px;
}
.valueCol {
flex-grow: 1;
padding-right: 5px;
@@ -59,10 +63,6 @@
height: 100%;
}
.customTrashIcon {
padding-top: 33px;
}
.rightPaneTrashIconImg {
vertical-align: top;
}

View File

@@ -142,11 +142,10 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
<div className="labelCol">
<TextField
className="edgeInput"
label={index === 0 && "Key"}
type="text"
id="propertyKeyNewVertexPane"
componentRef={input}
required
aria-required="true"
placeholder="Key"
autoComplete="off"
aria-label={`Enter value for propery ${index + 1}`}
@@ -154,11 +153,11 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onKeyChange(event, index)}
/>
</div>
<span className="mandatoryStar">*&nbsp;</span>
<div className="valueCol">
<TextField
className="edgeInput"
label={index === 0 && "Value"}
type="text"
placeholder="Value"
autoComplete="off"
@@ -170,8 +169,6 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
<div>
<Dropdown
role="combobox"
label={index === 0 && "Type"}
ariaLabel="Type"
placeholder="Select an option"
defaultSelectedKey={data.values[0].type}
style={{ width: 100 }}
@@ -184,7 +181,7 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
</div>
<div className="actionCol">
<div
className={`rightPaneTrashIcon rightPaneBtns ${index === 0 && "customTrashIcon"}`}
className="rightPaneTrashIcon rightPaneBtns"
tabIndex={0}
role="button"
aria-label={`Delete ${data.key}`}

View File

@@ -6,12 +6,12 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext";
import * as React from "react";
import create, { UseStore } from "zustand";
import { ConnectionStatusType, PoolIdType } from "../../../Common/Constants";
import { StyleConstants } from "../../../Common/StyleConstants";
import { Platform, configContext } from "../../../ConfigContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode";
@@ -93,18 +93,19 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
);
}
const rootStyle = isFabric()
? {
root: {
backgroundColor: "transparent",
padding: "2px 8px 0px 8px",
},
}
: {
root: {
backgroundColor: backgroundColor,
},
};
const rootStyle =
configContext.platform === Platform.Fabric
? {
root: {
backgroundColor: "transparent",
padding: "2px 8px 0px 8px",
},
}
: {
root: {
backgroundColor: backgroundColor,
},
};
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);

View File

@@ -37,25 +37,21 @@ describe("CommandBarComponentButtonFactory tests", () => {
expect(enableAzureSynapseLinkBtn).toBeDefined();
});
// TODO: Now that Tables API supports dataplane RBAC, calling createStaticCommandBarButtons will enable the
// Entra ID Login button, which causes this test to fail due to "Invalid hook call.". This seems to be
// unsupported in jest and needs to be tested with react-hooks-testing-library.
//
// it("Button should not be visible for Tables API", () => {
// updateUserContext({
// databaseAccount: {
// properties: {
// capabilities: [{ name: "EnableTable" }],
// },
// } as DatabaseAccount,
// });
//
// const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
// const enableAzureSynapseLinkBtn = buttons.find(
// (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
// );
// expect(enableAzureSynapseLinkBtn).toBeUndefined();
//});
it("Button should not be visible for Tables API", () => {
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableTable" }],
},
} as DatabaseAccount,
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
);
expect(enableAzureSynapseLinkBtn).toBeUndefined();
});
it("Button should not be visible for Cassandra API", () => {
updateUserContext({

View File

@@ -1,5 +1,4 @@
import { KeyboardAction } from "KeyboardShortcuts";
import { isDataplaneRbacSupported } from "Utils/APITypeUtils";
import * as React from "react";
import { useEffect, useState } from "react";
import AddSqlQueryIcon from "../../../../images/AddSqlQuery_16x16.svg";
@@ -62,7 +61,7 @@ export function createStaticCommandBarButtons(
}
}
if (isDataplaneRbacSupported(userContext.apiType)) {
if (userContext.apiType === "SQL") {
const [loginButtonProps, setLoginButtonProps] = useState<CommandButtonComponentProps | undefined>(undefined);
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
@@ -126,13 +125,13 @@ 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 +145,7 @@ export function createContextCommandBarButtons(
}
if (
useNotebook.getState().isShellEnabled &&
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
!selectedNodeState.isDatabaseNodeOrNoneSelected() &&
userContext.apiType === "Cassandra"
) {

View File

@@ -36,10 +36,6 @@
&:active {
background-color:@NotificationHigh;
}
&:focus {
.focusedBorder();
}
.statusBar {
.dataTypeIcons {

View File

@@ -81,6 +81,10 @@ export class NotificationConsoleComponent extends React.Component<
}
}
public setElememntRef = (element: HTMLElement): void => {
this.consoleHeaderElement = element;
};
public render(): JSX.Element {
const numInProgress = this.state.allConsoleData.filter(
(data: ConsoleData) => data.type === ConsoleDataType.InProgress,
@@ -97,9 +101,7 @@ export class NotificationConsoleComponent extends React.Component<
<div
className="notificationConsoleHeader"
id="notificationConsoleHeader"
role="button"
aria-label="Console"
aria-expanded={this.props.isConsoleExpanded}
ref={this.setElememntRef}
onClick={() => this.expandCollapseConsole()}
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => this.onExpandCollapseKeyPress(event)}
tabIndex={0}
@@ -107,15 +109,15 @@ export class NotificationConsoleComponent extends React.Component<
<div className="statusBar">
<span className="dataTypeIcons">
<span className="notificationConsoleHeaderIconWithData">
<img src={LoadingIcon} alt="In progress items" />
<img src={LoadingIcon} alt="in progress items" />
<span className="numInProgress">{numInProgress}</span>
</span>
<span className="notificationConsoleHeaderIconWithData">
<img src={ErrorBlackIcon} alt="Error items" />
<img src={ErrorBlackIcon} alt="error items" />
<span className="numErroredItems">{numErroredItems}</span>
</span>
<span className="notificationConsoleHeaderIconWithData">
<img src={infoBubbleIcon} alt="Info items" />
<img src={infoBubbleIcon} alt="info items" />
<span className="numInfoItems">{numInfoItems}</span>
</span>
</span>
@@ -127,10 +129,17 @@ export class NotificationConsoleComponent extends React.Component<
</span>
</span>
</div>
<div className="expandCollapseButton" data-test="NotificationConsole/ExpandCollapseButton">
<div
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
aria-expanded={!this.props.isConsoleExpanded}
>
<img
src={this.props.isConsoleExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.props.isConsoleExpanded ? "Collapse icon" : "Expand icon"}
alt={this.props.isConsoleExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
/>
</div>
</div>
@@ -250,6 +259,9 @@ export class NotificationConsoleComponent extends React.Component<
}
private onConsoleWasExpanded = (): void => {
if (this.props.isConsoleExpanded && this.consoleHeaderElement) {
this.consoleHeaderElement.focus();
}
useNotificationConsole.getState().setConsoleAnimationFinished(true);
};

View File

@@ -5,13 +5,10 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleContainer"
>
<div
aria-expanded={false}
aria-label="Console"
className="notificationConsoleHeader"
id="notificationConsoleHeader"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<div
@@ -24,7 +21,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="In progress items"
alt="in progress items"
src={{}}
/>
<span
@@ -37,7 +34,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Error items"
alt="error items"
src={{}}
/>
<span
@@ -50,7 +47,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Info items"
alt="info items"
src={{}}
/>
<span
@@ -74,11 +71,15 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
</span>
</div>
<div
aria-expanded={true}
aria-label="console button collapsed"
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="Expand icon"
alt="ChevronUpIcon"
src=""
/>
</div>
@@ -175,13 +176,10 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleContainer"
>
<div
aria-expanded={false}
aria-label="Console"
className="notificationConsoleHeader"
id="notificationConsoleHeader"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<div
@@ -194,7 +192,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="In progress items"
alt="in progress items"
src={{}}
/>
<span
@@ -207,7 +205,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Error items"
alt="error items"
src={{}}
/>
<span
@@ -220,7 +218,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Info items"
alt="info items"
src={{}}
/>
<span
@@ -246,11 +244,15 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
</span>
</div>
<div
aria-expanded={true}
aria-label="console button collapsed"
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="Expand icon"
alt="ChevronUpIcon"
src=""
/>
</div>

View File

@@ -2,7 +2,7 @@
* Notebook container related stuff
*/
import { useDialog } from "Explorer/Controls/Dialog";
import promiseRetry, { AbortError, Options } from "p-retry";
import promiseRetry, { AbortError } 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: Options;
private retryOptions: promiseRetry.Options;
private scheduleTimerId: NodeJS.Timeout;
constructor(private onConnectionLost: () => void) {

View File

@@ -1,6 +1,6 @@
// TODO convert this file to an action registry in order to have actions and their handlers be more tightly coupled.
import { configContext, Platform } from "ConfigContext";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricMirrored } from "Platform/Fabric/FabricUtil";
import React from "react";
import { ActionContracts } from "../../Contracts/ExplorerContracts";
import * as ViewModels from "../../Contracts/ViewModels";
@@ -58,9 +58,9 @@ function openCollectionTab(
}
if (
isFabricMirrored() &&
configContext.platform === Platform.Fabric &&
!(
// whitelist the tab kinds that are allowed to be opened in Fabric mirrored
// whitelist the tab kinds that are allowed to be opened in Fabric
(
action.tabKind === ActionContracts.TabKind.SQLDocuments ||
action.tabKind === ActionContracts.TabKind.SQLQuery

View File

@@ -1,6 +1,6 @@
import { shallow } from "enzyme";
import React from "react";
import Explorer from "../../Explorer";
import Explorer from "../Explorer";
import { AddCollectionPanel } from "./AddCollectionPanel";
const props = {

View File

@@ -21,28 +21,13 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import {
AllPropertiesIndexed,
AnalyticalStorageContent,
ContainerVectorPolicyTooltipContent,
FullTextPolicyDefault,
getPartitionKey,
getPartitionKeyName,
getPartitionKeyPlaceHolder,
getPartitionKeyTooltipText,
isFreeTierAccount,
isSynapseLinkEnabled,
parseUniqueKeys,
scrollToSection,
SharedDatabaseDefault,
shouldShowAnalyticalStoreOptions,
UniqueKeysHeader,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
FullTextPoliciesComponent,
getFullTextLanguageOptions,
} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import { useSidePanel } from "hooks/useSidePanel";
import { useTeachingBubble } from "hooks/useTeachingBubble";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import React from "react";
import { CollectionCreation } from "Shared/Constants";
import { Action } from "Shared/Telemetry/TelemetryConstants";
@@ -56,15 +41,15 @@ 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 { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator";
import Explorer from "../../Explorer";
import { useDatabases } from "../../useDatabases";
import { PanelFooterComponent } from "../PanelFooterComponent";
import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent";
import { PanelLoadingScreen } from "../PanelLoadingScreen";
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput";
import "../Controls/ThroughputInput/ThroughputInput.less";
import { ContainerSampleGenerator } from "../DataSamples/ContainerSampleGenerator";
import Explorer from "../Explorer";
import { useDatabases } from "../useDatabases";
import { PanelFooterComponent } from "./PanelFooterComponent";
import { PanelInfoErrorComponent } from "./PanelInfoErrorComponent";
import { PanelLoadingScreen } from "./PanelLoadingScreen";
export interface AddCollectionPanelProps {
explorer: Explorer;
@@ -72,6 +57,40 @@ export interface AddCollectionPanelProps {
isQuickstart?: boolean;
}
const SharedDatabaseDefault: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [],
excludedPaths: [
{
path: "/*",
},
],
};
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [
{
path: "/*",
indexes: [
{
kind: "Range",
dataType: "Number",
precision: -1,
},
{
kind: "Range",
dataType: "String",
precision: -1,
},
],
},
],
excludedPaths: [],
};
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
vectorEmbeddings: [],
};
@@ -124,7 +143,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
enableIndexing: true,
isSharded: userContext.apiType !== "Tables",
partitionKey: getPartitionKey(props.isQuickstart),
partitionKey: this.getPartitionKey(),
subPartitionKeys: [],
enableDedicatedThroughput: false,
createMongoWildCardIndex:
@@ -140,7 +159,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
vectorEmbeddingPolicy: [],
vectorIndexingPolicy: [],
vectorPolicyValidated: true,
fullTextPolicy: FullTextPolicyDefault,
fullTextPolicy: { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] },
fullTextIndexes: [],
fullTextPolicyValidated: true,
};
@@ -154,7 +173,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
componentDidUpdate(_prevProps: AddCollectionPanelProps, prevState: AddCollectionPanelState): void {
if (this.state.errorMessage && this.state.errorMessage !== prevState.errorMessage) {
scrollToSection("panelContainer");
this.scrollToSection("panelContainer");
}
}
@@ -171,7 +190,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/>
)}
{!this.state.errorMessage && isFreeTierAccount() && (
{!this.state.errorMessage && this.isFreeTierAccount() && (
<PanelInfoErrorComponent
message={getUpsellMessage(userContext.portalEnv, true, isFirstResourceCreated, true)}
messageType="info"
@@ -265,152 +284,150 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
<div className="panelMainContent">
{!(isFabricNative() && this.props.databaseId !== undefined) && (
<Stack hidden={userContext.apiType === "Tables"}>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Database {userContext.apiType === "Mongo" ? "name" : "id"}
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
<Stack hidden={userContext.apiType === "Tables"}>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Database {userContext.apiType === "Mongo" ? "name" : "id"}
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
/>
</TooltipHost>
</Stack>
/>
</TooltipHost>
</Stack>
{configContext.platform !== Platform.Fabric && (
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={this.state.createNewDatabase}
aria-label="Create new database"
aria-checked={this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
id="databaseCreateNew"
tabIndex={0}
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Create new</span>
<input
className="panelRadioBtn"
checked={!this.state.createNewDatabase}
aria-label="Use existing database"
aria-checked={!this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
tabIndex={0}
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Use existing</span>
</div>
</Stack>
)}
{this.state.createNewDatabase && (
<Stack className="panelGroupSpacing">
{configContext.platform !== Platform.Fabric && (
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
name="newDatabaseId"
id="newDatabaseId"
aria-required
required
type="text"
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder="Type a new database id"
size={40}
className="panelTextField"
aria-label="New database id, Type a new database id"
autoFocus
className="panelRadioBtn"
checked={this.state.createNewDatabase}
aria-label="Create new database"
aria-checked={this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
id="databaseCreateNew"
tabIndex={0}
value={this.state.newDatabaseId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
this.setState({ newDatabaseId: event.target.value })
}
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Create new</span>
{!isServerlessAccount() && (
<Stack horizontal>
<Checkbox
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
checked={this.state.isSharedThroughputChecked}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) =>
this.setState({ isSharedThroughputChecked: isChecked })
}
/>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`Throughput configured at the database level will be shared across all ${getCollectionName(
<input
className="panelRadioBtn"
checked={!this.state.createNewDatabase}
aria-label="Use existing database"
aria-checked={!this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
tabIndex={0}
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Use existing</span>
</div>
</Stack>
)}
{this.state.createNewDatabase && (
<Stack className="panelGroupSpacing">
<input
name="newDatabaseId"
id="newDatabaseId"
aria-required
required
type="text"
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder="Type a new database id"
size={40}
className="panelTextField"
aria-label="New database id, Type a new database id"
autoFocus
tabIndex={0}
value={this.state.newDatabaseId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
this.setState({ newDatabaseId: event.target.value })
}
/>
{!isServerlessAccount() && (
<Stack horizontal>
<Checkbox
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
checked={this.state.isSharedThroughputChecked}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) =>
this.setState({ isSharedThroughputChecked: isChecked })
}
/>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`Throughput configured at the database level will be shared across all ${getCollectionName(
true,
).toLocaleLowerCase()} within the database.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={`Throughput configured at the database level will be shared across all ${getCollectionName(
true,
).toLocaleLowerCase()} within the database.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={`Throughput configured at the database level will be shared across all ${getCollectionName(
true,
).toLocaleLowerCase()} within the database.`}
/>
</TooltipHost>
</Stack>
)}
/>
</TooltipHost>
</Stack>
)}
{!isServerlessAccount() && this.state.isSharedThroughputChecked && (
<ThroughputInput
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !isFirstResourceCreated}
isDatabase={true}
isSharded={this.state.isSharded}
isFreeTier={isFreeTierAccount()}
isQuickstart={this.props.isQuickstart}
setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)}
setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) =>
this.setState({ isThroughputCapExceeded })
}
onCostAcknowledgeChange={(isAcknowledge: boolean) => (this.isCostAcknowledged = isAcknowledge)}
/>
)}
</Stack>
)}
{!this.state.createNewDatabase && (
<Dropdown
ariaLabel="Choose an existing database"
styles={{ title: { height: 27, lineHeight: 27 }, dropdownItem: { fontSize: 12 } }}
style={{ width: 300, fontSize: 12 }}
placeholder="Choose an existing database"
options={this.getDatabaseOptions()}
onChange={(event: React.FormEvent<HTMLDivElement>, database: IDropdownOption) =>
this.setState({ selectedDatabaseId: database.key as string })
}
defaultSelectedKey={this.props.databaseId}
responsiveMode={999}
/>
)}
<Separator className="panelSeparator" />
</Stack>
)}
{!isServerlessAccount() && this.state.isSharedThroughputChecked && (
<ThroughputInput
showFreeTierExceedThroughputTooltip={this.isFreeTierAccount() && !isFirstResourceCreated}
isDatabase={true}
isSharded={this.state.isSharded}
isFreeTier={this.isFreeTierAccount()}
isQuickstart={this.props.isQuickstart}
setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)}
setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) =>
this.setState({ isThroughputCapExceeded })
}
onCostAcknowledgeChange={(isAcknowledge: boolean) => (this.isCostAcknowledged = isAcknowledge)}
/>
)}
</Stack>
)}
{!this.state.createNewDatabase && (
<Dropdown
ariaLabel="Choose an existing database"
styles={{ title: { height: 27, lineHeight: 27 }, dropdownItem: { fontSize: 12 } }}
style={{ width: 300, fontSize: 12 }}
placeholder="Choose an existing database"
options={this.getDatabaseOptions()}
onChange={(event: React.FormEvent<HTMLDivElement>, database: IDropdownOption) =>
this.setState({ selectedDatabaseId: database.key as string })
}
defaultSelectedKey={this.props.databaseId}
responsiveMode={999}
/>
)}
<Separator className="panelSeparator" />
</Stack>
<Stack>
<Stack horizontal>
@@ -439,8 +456,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
aria-required
required
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"
@@ -559,14 +576,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{getPartitionKeyName()}
{this.getPartitionKeyName()}
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={getPartitionKeyTooltipText()}>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.getPartitionKeyTooltipText()}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={getPartitionKeyTooltipText()}
ariaLabel={this.getPartitionKeyTooltipText()}
/>
</TooltipHost>
</Stack>
@@ -580,8 +600,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
required
size={40}
className="panelTextField"
placeholder={getPartitionKeyPlaceHolder()}
aria-label={getPartitionKeyName()}
placeholder={this.getPartitionKeyPlaceHolder()}
aria-label={this.getPartitionKeyName()}
pattern={userContext.apiType === "Gremlin" ? "^/[^/]*" : ".*"}
title={userContext.apiType === "Gremlin" ? "May not use composite partition key" : ""}
value={this.state.partitionKey}
@@ -619,8 +639,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={index > 0 ? 1 : 0}
className="panelTextField"
autoComplete="off"
placeholder={getPartitionKeyPlaceHolder(index)}
aria-label={getPartitionKeyName()}
placeholder={this.getPartitionKeyPlaceHolder(index)}
aria-label={this.getPartitionKeyName()}
pattern={".*"}
title={""}
value={subPartitionKey}
@@ -646,7 +666,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack>
);
})}
{!isFabricNative() && userContext.apiType === "SQL" && (
{userContext.apiType === "SQL" && (
<Stack className="panelGroupSpacing">
<DefaultButton
styles={{ root: { padding: 0, width: 200, height: 30 }, label: { fontSize: 12 } }}
@@ -711,10 +731,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{this.shouldShowCollectionThroughputInput() && (
<ThroughputInput
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !isFirstResourceCreated}
showFreeTierExceedThroughputTooltip={this.isFreeTierAccount() && !isFirstResourceCreated}
isDatabase={false}
isSharded={this.state.isSharded}
isFreeTier={isFreeTierAccount()}
isFreeTier={this.isFreeTierAccount()}
isQuickstart={this.props.isQuickstart}
setThroughputValue={(throughput: number) => (this.collectionThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)}
@@ -727,9 +747,29 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/>
)}
{!isFabricNative() && userContext.apiType === "SQL" && (
{userContext.apiType === "SQL" && (
<Stack>
{UniqueKeysHeader()}
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Unique keys
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key."
}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key."
}
/>
</TooltipHost>
</Stack>
{this.state.uniqueKeys.map((uniqueKey: string, i: number): JSX.Element => {
return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${i}`} horizontal>
@@ -777,10 +817,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack>
)}
{shouldShowAnalyticalStoreOptions() && (
{this.shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing">
<Text className="panelTextBold" variant="small">
{AnalyticalStorageContent()}
{this.getAnalyticalStorageContent()}
</Text>
<Stack horizontal verticalAlign="center">
@@ -788,7 +828,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<input
className="panelRadioBtn"
checked={this.state.enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore"
@@ -803,7 +843,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<input
className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore"
@@ -817,7 +857,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</div>
</Stack>
{!isSynapseLinkEnabled() && (
{!this.isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "}
@@ -847,9 +887,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleVectorPolicySectionContent");
this.scrollToSection("collapsibleVectorPolicySectionContent");
}}
tooltipContent={ContainerVectorPolicyTooltipContent()}
tooltipContent={this.getContainerVectorPolicyTooltipContent()}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
@@ -875,7 +915,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
title="Container Full Text Search Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleFullTextPolicySectionContent");
this.scrollToSection("collapsibleFullTextPolicySectionContent");
}}
//TODO: uncomment when learn more text becomes available
// tooltipContent={this.getContainerFullTextPolicyTooltipContent()}
@@ -897,13 +937,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</CollapsibleSectionComponent>
</Stack>
)}
{!isFabricNative() && userContext.apiType !== "Tables" && (
{userContext.apiType !== "Tables" && (
<CollapsibleSectionComponent
title="Advanced"
isExpandedByDefault={false}
onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
scrollToSection("collapsibleAdvancedSectionContent");
this.scrollToSection("collapsibleAdvancedSectionContent");
}}
>
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
@@ -1013,6 +1053,31 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}));
}
private getPartitionKeyName(isLowerCase?: boolean): string {
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
}
private getPartitionKeyPlaceHolder(index?: number): string {
switch (userContext.apiType) {
case "Mongo":
return "e.g., categoryId";
case "Gremlin":
return "e.g., /address";
case "SQL":
return `${
index === undefined
? "Required - first partition key e.g., /TenantId"
: index === 0
? "second partition key e.g., /UserId"
: "third partition key e.g., /SessionId"
}`;
default:
return "e.g., /address/zipCode";
}
}
private onCreateNewDatabaseRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
if (event.target.checked && !this.state.createNewDatabase) {
this.setState({
@@ -1100,12 +1165,48 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return !!selectedDatabase?.offer();
}
private isFreeTierAccount(): boolean {
return userContext.databaseAccount?.properties?.enableFreeTier;
}
private getFreeTierIndexingText(): string {
return this.state.enableIndexing
? "All properties in your documents will be indexed by default for flexible and efficient queries."
: "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations.";
}
private getPartitionKeyTooltipText(): string {
if (userContext.apiType === "Mongo") {
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = `The ${this.getPartitionKeyName(
true,
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
if (userContext.apiType === "SQL") {
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
}
private getPartitionKey(): string {
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
return "";
}
if (userContext.features.partitionKeyDefault) {
return userContext.apiType === "SQL" ? "/id" : "_id";
}
if (userContext.features.partitionKeyDefault2) {
return userContext.apiType === "SQL" ? "/pk" : "pk";
}
if (this.props.isQuickstart) {
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
}
return "";
}
private getPartitionKeySubtext(): string {
if (
userContext.features.partitionKeyDefault &&
@@ -1117,6 +1218,34 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return "";
}
private getAnalyticalStorageContent(): JSX.Element {
return (
<Text variant="small">
Enable analytical store capability to perform near real-time analytics on your operational data, without
impacting the performance of transactional workloads.{" "}
<Link
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
target="_blank"
href="https://aka.ms/analytical-store-overview"
>
Learn more
</Link>
</Text>
);
}
private getContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
//TODO: uncomment when learn more text becomes available
// private getContainerFullTextPolicyTooltipContent(): JSX.Element {
// return (
@@ -1131,7 +1260,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
// }
private shouldShowCollectionThroughputInput(): boolean {
if (isFabricNative() || isServerlessAccount()) {
if (isServerlessAccount()) {
return false;
}
@@ -1147,7 +1276,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
private shouldShowIndexingOptionsForFreeTierAccount(): boolean {
if (!isFreeTierAccount()) {
if (!this.isFreeTierAccount()) {
return false;
}
@@ -1156,6 +1285,39 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
: this.isSelectedDatabaseSharedThroughput();
}
private shouldShowAnalyticalStoreOptions(): boolean {
if (configContext.platform === Platform.Emulator) {
return false;
}
switch (userContext.apiType) {
case "SQL":
case "Mongo":
return true;
default:
return false;
}
}
private isSynapseLinkEnabled(): boolean {
if (!userContext.databaseAccount) {
return false;
}
const { properties } = userContext.databaseAccount;
if (!properties) {
return false;
}
if (properties.enableAnalyticalStorage) {
return true;
}
return properties.capabilities?.some(
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
);
}
private shouldShowVectorSearchParameters() {
return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput());
}
@@ -1236,11 +1398,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
private getAnalyticalStorageTtl(): number {
if (!isSynapseLinkEnabled()) {
if (!this.isSynapseLinkEnabled()) {
return undefined;
}
if (!shouldShowAnalyticalStoreOptions()) {
if (!this.shouldShowAnalyticalStoreOptions()) {
return undefined;
}
@@ -1254,6 +1416,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return Constants.AnalyticalStorageTtl.Disabled;
}
private scrollToSection(id: string): void {
document.getElementById(id)?.scrollIntoView();
}
private getSampleDBName(): string {
const existingSampleDBs = useDatabases
.getState()
@@ -1288,7 +1454,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
partitionKeyString = "/'$pk'";
}
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(this.state.uniqueKeys);
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys();
const partitionKeyVersion = this.state.useHashV1 ? undefined : 2;
const partitionKey: DataModels.PartitionKey = partitionKeyString
? {

View File

@@ -1,217 +0,0 @@
import { DirectionalHint, Icon, Link, Stack, Text, TooltipHost } from "@fluentui/react";
import * as Constants from "Common/Constants";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { getFullTextLanguageOptions } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import React from "react";
import { userContext } from "UserContext";
export function getPartitionKeyTooltipText(): string {
if (userContext.apiType === "Mongo") {
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = `The ${getPartitionKeyName(
true,
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
if (userContext.apiType === "SQL") {
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
}
export function getPartitionKeyName(isLowerCase?: boolean): string {
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
}
export function getPartitionKeyPlaceHolder(index?: number): string {
switch (userContext.apiType) {
case "Mongo":
return "e.g., categoryId";
case "Gremlin":
return "e.g., /address";
case "SQL":
return `${
index === undefined
? "Required - first partition key e.g., /TenantId"
: index === 0
? "second partition key e.g., /UserId"
: "third partition key e.g., /SessionId"
}`;
default:
return "e.g., /address/zipCode";
}
}
export function getPartitionKey(isQuickstart?: boolean): string {
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
return "";
}
if (userContext.features.partitionKeyDefault) {
return userContext.apiType === "SQL" ? "/id" : "_id";
}
if (userContext.features.partitionKeyDefault2) {
return userContext.apiType === "SQL" ? "/pk" : "pk";
}
if (isQuickstart) {
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
}
return "";
}
export function isFreeTierAccount(): boolean {
return userContext.databaseAccount?.properties?.enableFreeTier;
}
export function UniqueKeysHeader(): JSX.Element {
const tooltipContent =
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key.";
return (
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Unique keys
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
</TooltipHost>
</Stack>
);
}
export function shouldShowAnalyticalStoreOptions(): boolean {
if (isFabricNative() || configContext.platform === Platform.Emulator) {
return false;
}
switch (userContext.apiType) {
case "SQL":
case "Mongo":
return true;
default:
return false;
}
}
export function AnalyticalStorageContent(): JSX.Element {
return (
<Text variant="small">
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
the performance of transactional workloads.{" "}
<Link
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
target="_blank"
href="https://aka.ms/analytical-store-overview"
>
Learn more
</Link>
</Text>
);
}
export function isSynapseLinkEnabled(): boolean {
if (!userContext.databaseAccount) {
return false;
}
const { properties } = userContext.databaseAccount;
if (!properties) {
return false;
}
if (properties.enableAnalyticalStorage) {
return true;
}
return properties.capabilities?.some(
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
);
}
export function scrollToSection(id: string): void {
document.getElementById(id)?.scrollIntoView();
}
export function ContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
export function parseUniqueKeys(uniqueKeys: string[]): DataModels.UniqueKeyPolicy {
if (uniqueKeys?.length === 0) {
return undefined;
}
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] };
uniqueKeys.forEach((uniqueKey: string) => {
if (uniqueKey) {
const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0);
const trimmedPaths: string[] = validPaths?.map((path) => path.trim());
if (trimmedPaths?.length > 0) {
if (userContext.apiType === "Mongo") {
trimmedPaths.map((path) => {
const transformedPath = path.split(".").join("/");
if (transformedPath[0] !== "/") {
return "/" + transformedPath;
}
return transformedPath;
});
}
uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths });
}
}
});
return uniqueKeyPolicy;
}
export const SharedDatabaseDefault: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [],
excludedPaths: [
{
path: "/*",
},
],
};
export const FullTextPolicyDefault: DataModels.FullTextPolicy = {
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
fullTextPaths: [],
};
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [
{
path: "/*",
indexes: [
{
kind: "Range",
dataType: "Number",
precision: -1,
},
{
kind: "Range",
dataType: "String",
precision: -1,
},
],
},
],
excludedPaths: [],
};

View File

@@ -1,6 +1,5 @@
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";
@@ -205,8 +204,8 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
type="text"
aria-required="true"
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
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

@@ -1,28 +0,0 @@
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

@@ -1,431 +0,0 @@
import {
DirectionalHint,
Dropdown,
DropdownMenuItemType,
Icon,
IDropdownOption,
Link,
Separator,
Stack,
Text,
TooltipHost,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { createGlobalSecondaryIndex } from "Common/dataAccess/createMaterializedView";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import * as DataModels from "Contracts/DataModels";
import { FullTextIndex, FullTextPolicy, VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { Collection, Database } from "Contracts/ViewModels";
import Explorer from "Explorer/Explorer";
import {
AllPropertiesIndexed,
FullTextPolicyDefault,
getPartitionKey,
isSynapseLinkEnabled,
parseUniqueKeys,
scrollToSection,
shouldShowAnalyticalStoreOptions,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import {
chooseSourceContainerStyle,
chooseSourceContainerStyles,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles";
import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent";
import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent";
import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent";
import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent";
import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent";
import { UniqueKeysComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent";
import { VectorSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent";
import { PanelFooterComponent } from "Explorer/Panes/PanelFooterComponent";
import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent";
import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen";
import { useDatabases } from "Explorer/useDatabases";
import { useSidePanel } from "hooks/useSidePanel";
import React, { useEffect, useState } from "react";
import { CollectionCreation } from "Shared/Constants";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { isFullTextSearchEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
export interface AddGlobalSecondaryIndexPanelProps {
explorer: Explorer;
sourceContainer?: Collection;
}
export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanelProps): JSX.Element => {
const { explorer, sourceContainer } = props;
const [sourceContainerOptions, setSourceContainerOptions] = useState<IDropdownOption[]>();
const [selectedSourceContainer, setSelectedSourceContainer] = useState<Collection>(sourceContainer);
const [globalSecondaryIndexId, setGlobalSecondaryIndexId] = useState<string>();
const [definition, setDefinition] = useState<string>();
const [partitionKey, setPartitionKey] = useState<string>(getPartitionKey());
const [subPartitionKeys, setSubPartitionKeys] = useState<string[]>([]);
const [useHashV1, setUseHashV1] = useState<boolean>();
const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState<boolean>();
const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState<boolean>();
const [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 [fullTextPolicy, setFullTextPolicy] = useState<FullTextPolicy>(FullTextPolicyDefault);
const [fullTextIndexes, setFullTextIndexes] = useState<FullTextIndex[]>();
const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState<boolean>();
const [errorMessage, setErrorMessage] = useState<string>();
const [showErrorDetails, setShowErrorDetails] = useState<boolean>();
const [isExecuting, setIsExecuting] = useState<boolean>();
useEffect(() => {
const sourceContainerOptions: IDropdownOption[] = [];
useDatabases.getState().databases.forEach((database: Database) => {
sourceContainerOptions.push({
key: database.rid,
text: database.id(),
itemType: DropdownMenuItemType.Header,
});
database.collections().forEach((collection: Collection) => {
const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition();
sourceContainerOptions.push({
key: collection.rid,
text: collection.id(),
disabled: isGlobalSecondaryIndex,
...(isGlobalSecondaryIndex && {
title: "This is a global secondary index.",
}),
data: collection,
});
});
});
setSourceContainerOptions(sourceContainerOptions);
}, []);
useEffect(() => {
scrollToSection("panelContainer");
}, [errorMessage]);
let globalSecondaryIndexThroughput: number;
let isGlobalSecondaryIndexAutoscale: boolean;
let isCostAcknowledged: boolean;
const globalSecondaryIndexThroughputOnChange = (globalSecondaryIndexThroughputValue: number): void => {
globalSecondaryIndexThroughput = globalSecondaryIndexThroughputValue;
};
const isGlobalSecondaryIndexAutoscaleOnChange = (isGlobalSecondaryIndexAutoscaleValue: boolean): void => {
isGlobalSecondaryIndexAutoscale = isGlobalSecondaryIndexAutoscaleValue;
};
const isCostAknowledgedOnChange = (isCostAcknowledgedValue: boolean): void => {
isCostAcknowledged = isCostAcknowledgedValue;
};
const isSelectedSourceContainerSharedThroughput = (): boolean => {
if (!selectedSourceContainer) {
return false;
}
return !!selectedSourceContainer.getDatabase().offer();
};
const showCollectionThroughputInput = (): boolean => {
if (isServerlessAccount()) {
return false;
}
if (enableDedicatedThroughput) {
return true;
}
return !!selectedSourceContainer && !isSelectedSourceContainerSharedThroughput();
};
const showVectorSearchParameters = (): boolean => {
return isVectorSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
};
const showFullTextSearchParameters = (): boolean => {
return isFullTextSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
};
const getAnalyticalStorageTtl = (): number => {
if (!isSynapseLinkEnabled()) {
return undefined;
}
if (!shouldShowAnalyticalStoreOptions()) {
return undefined;
}
if (enableAnalyticalStore) {
// TODO: always default to 90 days once the backend hotfix is deployed
return userContext.features.ttl90Days
? Constants.AnalyticalStorageTtl.Days90
: Constants.AnalyticalStorageTtl.Infinite;
}
return Constants.AnalyticalStorageTtl.Disabled;
};
const validateInputs = (): boolean => {
if (!selectedSourceContainer) {
setErrorMessage("Please select a source container");
return false;
}
if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
const errorMessage = isGlobalSecondaryIndexAutoscale
? "Please acknowledge the estimated monthly spend."
: "Please acknowledge the estimated daily spend.";
setErrorMessage(errorMessage);
return false;
}
if (globalSecondaryIndexThroughput > CollectionCreation.MaxRUPerPartition) {
setErrorMessage("Unsharded collections support up to 10,000 RUs");
return false;
}
if (showVectorSearchParameters()) {
if (!vectorPolicyValidated) {
setErrorMessage("Please fix errors in container vector policy");
return false;
}
if (!fullTextPolicyValidated) {
setErrorMessage("Please fix errors in container full text search policy");
return false;
}
}
return true;
};
const submit = async (event?: React.FormEvent<HTMLFormElement>): Promise<void> => {
event?.preventDefault();
if (!validateInputs()) {
return;
}
const globalSecondaryIdTrimmed: string = globalSecondaryIndexId.trim();
const globalSecondaryIndexDefinition: DataModels.MaterializedViewDefinition = {
sourceCollectionId: selectedSourceContainer.id(),
definition: definition,
};
const partitionKeyTrimmed: string = partitionKey.trim();
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(uniqueKeys);
const partitionKeyVersion = useHashV1 ? undefined : 2;
const partitionKeyPaths: DataModels.PartitionKey = partitionKeyTrimmed
? {
paths: [
partitionKeyTrimmed,
...(userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? subPartitionKeys : []),
],
kind: userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? "MultiHash" : "Hash",
version: partitionKeyVersion,
}
: undefined;
const indexingPolicy: DataModels.IndexingPolicy = AllPropertiesIndexed;
let vectorEmbeddingPolicyFinal: DataModels.VectorEmbeddingPolicy;
if (showVectorSearchParameters()) {
indexingPolicy.vectorIndexes = vectorIndexingPolicy;
vectorEmbeddingPolicyFinal = {
vectorEmbeddings: vectorEmbeddingPolicy,
};
}
if (showFullTextSearchParameters()) {
indexingPolicy.fullTextIndexes = fullTextIndexes;
}
const telemetryData: TelemetryProcessor.TelemetryData = {
database: {
id: selectedSourceContainer.databaseId,
shared: isSelectedSourceContainerSharedThroughput(),
},
collection: {
id: globalSecondaryIdTrimmed,
throughput: globalSecondaryIndexThroughput,
isAutoscale: isGlobalSecondaryIndexAutoscale,
partitionKeyPaths,
uniqueKeyPolicy,
collectionWithDedicatedThroughput: enableDedicatedThroughput,
},
subscriptionQuotaId: userContext.quotaId,
dataExplorerArea: Constants.Areas.ContextualPane,
};
const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData);
const databaseLevelThroughput: boolean = isSelectedSourceContainerSharedThroughput() && !enableDedicatedThroughput;
let offerThroughput: number;
let autoPilotMaxThroughput: number;
if (!databaseLevelThroughput) {
if (isGlobalSecondaryIndexAutoscale) {
autoPilotMaxThroughput = globalSecondaryIndexThroughput;
} else {
offerThroughput = globalSecondaryIndexThroughput;
}
}
const createGlobalSecondaryIndexParams: DataModels.CreateMaterializedViewsParams = {
materializedViewId: globalSecondaryIdTrimmed,
materializedViewDefinition: globalSecondaryIndexDefinition,
databaseId: selectedSourceContainer.databaseId,
databaseLevelThroughput: databaseLevelThroughput,
offerThroughput: offerThroughput,
autoPilotMaxThroughput: autoPilotMaxThroughput,
analyticalStorageTtl: getAnalyticalStorageTtl(),
indexingPolicy: indexingPolicy,
partitionKey: partitionKeyPaths,
uniqueKeyPolicy: uniqueKeyPolicy,
vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal,
fullTextPolicy: fullTextPolicy,
};
setIsExecuting(true);
try {
await createGlobalSecondaryIndex(createGlobalSecondaryIndexParams);
await explorer.refreshAllDatabases();
TelemetryProcessor.traceSuccess(Action.CreateGlobalSecondaryIndex, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
} catch (error) {
const errorMessage: string = getErrorMessage(error);
setErrorMessage(errorMessage);
setShowErrorDetails(true);
const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) };
TelemetryProcessor.traceFailure(Action.CreateGlobalSecondaryIndex, failureTelemetryData, startKey);
} finally {
setIsExecuting(false);
}
};
return (
<form className="panelFormWrapper" id="panelGlobalSecondaryIndex" onSubmit={submit}>
{errorMessage && (
<PanelInfoErrorComponent message={errorMessage} messageType="error" showErrorDetails={showErrorDetails} />
)}
<div className="panelMainContent">
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Source container id
</Text>
</Stack>
<Dropdown
placeholder="Choose source container"
options={sourceContainerOptions}
defaultSelectedKey={selectedSourceContainer?.rid}
styles={chooseSourceContainerStyles()}
style={chooseSourceContainerStyle()}
onChange={(_, options: IDropdownOption) => setSelectedSourceContainer(options.data as Collection)}
/>
<Separator className="panelSeparator" />
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Global secondary index container id
</Text>
</Stack>
<input
id="globalSecondaryIndexId"
type="text"
aria-required
required
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={`e.g., indexbyEmailId`}
size={40}
className="panelTextField"
value={globalSecondaryIndexId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGlobalSecondaryIndexId(event.target.value)}
/>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Global secondary index definition
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={
<Link
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
Learn more about defining global secondary indexes.
</Link>
}
>
<Icon role="button" iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
</Stack>
<input
id="globalSecondaryIndexDefinition"
type="text"
aria-required
required
autoComplete="off"
placeholder={"SELECT c.email, c.accountId FROM c"}
size={40}
className="panelTextField"
value={definition || ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDefinition(event.target.value)}
/>
<PartitionKeyComponent
{...{ partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 }}
/>
<ThroughputComponent
{...{
enableDedicatedThroughput,
setEnabledDedicatedThroughput,
isSelectedSourceContainerSharedThroughput,
showCollectionThroughputInput,
globalSecondaryIndexThroughputOnChange,
isGlobalSecondaryIndexAutoscaleOnChange,
setIsThroughputCapExceeded,
isCostAknowledgedOnChange,
}}
/>
<UniqueKeysComponent {...{ uniqueKeys, setUniqueKeys }} />
{shouldShowAnalyticalStoreOptions() && (
<AnalyticalStoreComponent {...{ explorer, enableAnalyticalStore, setEnableAnalyticalStore }} />
)}
{showVectorSearchParameters() && (
<VectorSearchComponent
{...{
vectorEmbeddingPolicy,
setVectorEmbeddingPolicy,
vectorIndexingPolicy,
setVectorIndexingPolicy,
vectorPolicyValidated,
setVectorPolicyValidated,
}}
/>
)}
{showFullTextSearchParameters() && (
<FullTextSearchComponent
{...{ fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated }}
/>
)}
<AdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
</Stack>
</div>
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={isThroughputCapExceeded} />
{isExecuting && <PanelLoadingScreen />}
</form>
);
};

View File

@@ -1,15 +0,0 @@
import { IDropdownStyleProps, IDropdownStyles, IStyleFunctionOrObject } from "@fluentui/react";
import { CSSProperties } from "react";
export function chooseSourceContainerStyles(): IStyleFunctionOrObject<IDropdownStyleProps, IDropdownStyles> {
return {
title: { height: 27, lineHeight: 27 },
dropdownItem: { fontSize: 12 },
dropdownItemDisabled: { fontSize: 12 },
dropdownItemSelected: { fontSize: 12 },
};
}
export function chooseSourceContainerStyle(): CSSProperties {
return { width: 300, fontSize: 12 };
}

View File

@@ -1,54 +0,0 @@
import { Checkbox, Icon, Link, Stack, Text } from "@fluentui/react";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
export interface AdvancedComponentProps {
useHashV1: boolean;
setUseHashV1: React.Dispatch<React.SetStateAction<boolean>>;
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
}
export const AdvancedComponent = (props: AdvancedComponentProps): JSX.Element => {
const { useHashV1, setUseHashV1, setSubPartitionKeys } = props;
const useHashV1CheckboxOnChange = (isChecked: boolean): void => {
setUseHashV1(isChecked);
setSubPartitionKeys([]);
};
return (
<CollapsibleSectionComponent
title="Advanced"
isExpandedByDefault={false}
onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddGlobalSecondaryIndexPaneAdvancedSection);
scrollToSection("collapsibleAdvancedSectionContent");
}}
>
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
<Checkbox
label="My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2)"
checked={useHashV1}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center", wordWrap: "break-word", whiteSpace: "break-spaces" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => {
useHashV1CheckboxOnChange(isChecked);
}}
/>
<Text variant="small">
<Icon iconName="InfoSolid" className="removeIcon" /> To ensure compatibility with older SDKs, the created
container will use a legacy partitioning scheme that supports partition key values of size only up to 101
bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "}
<Link href="https://aka.ms/cosmos-large-pk" target="_blank">
Learn more
</Link>
</Text>
</Stack>
</CollapsibleSectionComponent>
);
};

View File

@@ -1,99 +0,0 @@
import { DefaultButton, Link, Stack, Text } from "@fluentui/react";
import * as Constants from "Common/Constants";
import Explorer from "Explorer/Explorer";
import {
AnalyticalStorageContent,
isSynapseLinkEnabled,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
import { getCollectionName } from "Utils/APITypeUtils";
export interface AnalyticalStoreComponentProps {
explorer: Explorer;
enableAnalyticalStore: boolean;
setEnableAnalyticalStore: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => {
const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props;
const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => {
if (checked && !enableAnalyticalStore) {
setEnableAnalyticalStore(true);
}
};
const onDisableAnalyticalStoreRadioButtonnChange = (checked: boolean): void => {
if (checked && enableAnalyticalStore) {
setEnableAnalyticalStore(false);
}
};
return (
<Stack className="panelGroupSpacing">
<Text className="panelTextBold" variant="small">
{AnalyticalStorageContent()}
</Text>
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="enableAnalyticalStoreBtn"
tabIndex={0}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onEnableAnalyticalStoreRadioButtonChange(event.target.checked);
}}
/>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="disableAnalyticalStoreBtn"
tabIndex={0}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onDisableAnalyticalStoreRadioButtonnChange(event.target.checked);
}}
/>
<span className="panelRadioBtnLabel">Off</span>
</div>
</Stack>
{!isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store {getCollectionName().toLocaleLowerCase()}.
Enable Synapse Link for this Cosmos DB account.{" "}
<Link
href="https://aka.ms/cosmosdb-synapselink"
target="_blank"
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
className="capacitycalculator-link"
>
Learn more
</Link>
</Text>
<DefaultButton
text="Enable"
onClick={() => explorer.openEnableSynapseLinkDialog()}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
/>
</Stack>
)}
</Stack>
);
};

View File

@@ -1,45 +0,0 @@
import { Stack } from "@fluentui/react";
import { FullTextIndex, FullTextPolicy } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface FullTextSearchComponentProps {
fullTextPolicy: FullTextPolicy;
setFullTextPolicy: React.Dispatch<React.SetStateAction<FullTextPolicy>>;
setFullTextIndexes: React.Dispatch<React.SetStateAction<FullTextIndex[]>>;
setFullTextPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
}
export const FullTextSearchComponent = (props: FullTextSearchComponentProps): JSX.Element => {
const { fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated } = props;
return (
<Stack>
<CollapsibleSectionComponent
title="Container Full Text Search Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleFullTextPolicySectionContent");
}}
>
<Stack id="collapsibleFullTextPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<FullTextPoliciesComponent
fullTextPolicy={fullTextPolicy}
onFullTextPathChange={(
fullTextPolicy: FullTextPolicy,
fullTextIndexes: FullTextIndex[],
fullTextPolicyValidated: boolean,
) => {
setFullTextPolicy(fullTextPolicy);
setFullTextIndexes(fullTextIndexes);
setFullTextPolicyValidated(fullTextPolicyValidated);
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
);
};

View File

@@ -1,132 +0,0 @@
import { DefaultButton, DirectionalHint, Icon, IconButton, Link, Stack, Text, TooltipHost } from "@fluentui/react";
import * as Constants from "Common/Constants";
import {
getPartitionKeyName,
getPartitionKeyPlaceHolder,
getPartitionKeyTooltipText,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface PartitionKeyComponentProps {
partitionKey?: string;
setPartitionKey: React.Dispatch<React.SetStateAction<string>>;
subPartitionKeys: string[];
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
useHashV1: boolean;
}
export const PartitionKeyComponent = (props: PartitionKeyComponentProps): JSX.Element => {
const { partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 } = props;
const partitionKeyValueOnChange = (value: string): void => {
if (!partitionKey && !value.startsWith("/")) {
setPartitionKey("/" + value);
} else {
setPartitionKey(value);
}
};
const subPartitionKeysValueOnChange = (value: string, index: number): void => {
const updatedSubPartitionKeys: string[] = [...subPartitionKeys];
if (!updatedSubPartitionKeys[index] && !value.startsWith("/")) {
updatedSubPartitionKeys[index] = "/" + value.trim();
} else {
updatedSubPartitionKeys[index] = value.trim();
}
setSubPartitionKeys(updatedSubPartitionKeys);
};
return (
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Partition key
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={getPartitionKeyTooltipText()}>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
</Stack>
<input
type="text"
id="addGlobalSecondaryIndex-partitionKeyValue"
aria-required
required
size={40}
className="panelTextField"
placeholder={getPartitionKeyPlaceHolder()}
aria-label={getPartitionKeyName()}
pattern=".*"
value={partitionKey}
style={{ marginBottom: 8 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
partitionKeyValueOnChange(event.target.value);
}}
/>
{subPartitionKeys.map((subPartitionKey: string, subPartitionKeyIndex: number) => {
return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${subPartitionKeyIndex}`} horizontal>
<div
style={{
width: "20px",
border: "solid",
borderWidth: "0px 0px 1px 1px",
marginRight: "5px",
}}
></div>
<input
type="text"
id="addGlobalSecondaryIndex-partitionKeyValue"
key={`addGlobalSecondaryIndex-partitionKeyValue_${subPartitionKeyIndex}`}
aria-required
required
size={40}
tabIndex={subPartitionKeyIndex > 0 ? 1 : 0}
className="panelTextField"
autoComplete="off"
placeholder={getPartitionKeyPlaceHolder(subPartitionKeyIndex)}
aria-label={getPartitionKeyName()}
pattern={".*"}
title={""}
value={subPartitionKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
subPartitionKeysValueOnChange(event.target.value, subPartitionKeyIndex);
}}
/>
<IconButton
iconProps={{ iconName: "Delete" }}
style={{ height: 27 }}
onClick={() => {
const updatedSubPartitionKeys = subPartitionKeys.filter(
(_, subPartitionKeyIndexToRemove) => subPartitionKeyIndex !== subPartitionKeyIndexToRemove,
);
setSubPartitionKeys(updatedSubPartitionKeys);
}}
/>
</Stack>
);
})}
<Stack className="panelGroupSpacing">
<DefaultButton
styles={{ root: { padding: 0, width: 200, height: 30 }, label: { fontSize: 12 } }}
hidden={useHashV1}
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
>
Add hierarchical partition key
</DefaultButton>
{subPartitionKeys.length > 0 && (
<Text variant="small">
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to partition your
data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview
JavaScript V3 SDK.{" "}
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
Learn more
</Link>
</Text>
)}
</Stack>
</Stack>
);
};

View File

@@ -1,71 +0,0 @@
import { Checkbox, Stack } from "@fluentui/react";
import { ThroughputInput } from "Explorer/Controls/ThroughputInput/ThroughputInput";
import { isFreeTierAccount } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import { useDatabases } from "Explorer/useDatabases";
import React from "react";
import { getCollectionName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils";
export interface ThroughputComponentProps {
enableDedicatedThroughput: boolean;
setEnabledDedicatedThroughput: React.Dispatch<React.SetStateAction<boolean>>;
isSelectedSourceContainerSharedThroughput: () => boolean;
showCollectionThroughputInput: () => boolean;
globalSecondaryIndexThroughputOnChange: (globalSecondaryIndexThroughputValue: number) => void;
isGlobalSecondaryIndexAutoscaleOnChange: (isGlobalSecondaryIndexAutoscaleValue: boolean) => void;
setIsThroughputCapExceeded: React.Dispatch<React.SetStateAction<boolean>>;
isCostAknowledgedOnChange: (isCostAknowledgedValue: boolean) => void;
}
export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Element => {
const {
enableDedicatedThroughput,
setEnabledDedicatedThroughput,
isSelectedSourceContainerSharedThroughput,
showCollectionThroughputInput,
globalSecondaryIndexThroughputOnChange,
isGlobalSecondaryIndexAutoscaleOnChange,
setIsThroughputCapExceeded,
isCostAknowledgedOnChange,
} = props;
return (
<Stack>
{!isServerlessAccount() && isSelectedSourceContainerSharedThroughput() && (
<Stack horizontal verticalAlign="center">
<Checkbox
label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
checked={enableDedicatedThroughput}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" },
}}
onChange={(_, isChecked: boolean) => setEnabledDedicatedThroughput(isChecked)}
/>
</Stack>
)}
{showCollectionThroughputInput() && (
<ThroughputInput
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !useDatabases.getState().isFirstResourceCreated()}
isDatabase={false}
isSharded={false}
isFreeTier={isFreeTierAccount()}
isQuickstart={false}
setThroughputValue={(throughput: number) => {
globalSecondaryIndexThroughputOnChange(throughput);
}}
setIsAutoscale={(isAutoscale: boolean) => {
isGlobalSecondaryIndexAutoscaleOnChange(isAutoscale);
}}
setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => {
setIsThroughputCapExceeded(isThroughputCapExceeded);
}}
onCostAcknowledgeChange={(isAcknowledged: boolean) => {
isCostAknowledgedOnChange(isAcknowledged);
}}
/>
)}
</Stack>
);
};

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 UniqueKeysComponentProps {
uniqueKeys: string[];
setUniqueKeys: React.Dispatch<React.SetStateAction<string[]>>;
}
export const UniqueKeysComponent = (props: UniqueKeysComponentProps): 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,58 +0,0 @@
import { Stack } from "@fluentui/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import {
ContainerVectorPolicyTooltipContent,
scrollToSection,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface VectorSearchComponentProps {
vectorEmbeddingPolicy: VectorEmbedding[];
setVectorEmbeddingPolicy: React.Dispatch<React.SetStateAction<VectorEmbedding[]>>;
vectorIndexingPolicy: VectorIndex[];
setVectorIndexingPolicy: React.Dispatch<React.SetStateAction<VectorIndex[]>>;
setVectorPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
}
export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => {
const {
vectorEmbeddingPolicy,
setVectorEmbeddingPolicy,
vectorIndexingPolicy,
setVectorIndexingPolicy,
setVectorPolicyValidated,
} = props;
return (
<Stack>
<CollapsibleSectionComponent
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleVectorPolicySectionContent");
}}
tooltipContent={ContainerVectorPolicyTooltipContent()}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<VectorEmbeddingPoliciesComponent
vectorEmbeddings={vectorEmbeddingPolicy}
vectorIndexes={vectorIndexingPolicy}
onVectorEmbeddingChange={(
vectorEmbeddingPolicy: VectorEmbedding[],
vectorIndexingPolicy: VectorIndex[],
vectorPolicyValidated: boolean,
) => {
setVectorEmbeddingPolicy(vectorEmbeddingPolicy);
setVectorIndexingPolicy(vectorIndexingPolicy);
setVectorPolicyValidated(vectorPolicyValidated);
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
);
};

View File

@@ -1,190 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
<form
className="panelFormWrapper"
id="panelGlobalSecondaryIndex"
onSubmit={[Function]}
>
<div
className="panelMainContent"
>
<Stack>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
* 
</span>
<Text
className="panelTextBold"
variant="small"
>
Source container id
</Text>
</Stack>
<Dropdown
onChange={[Function]}
placeholder="Choose source container"
style={
{
"fontSize": 12,
"width": 300,
}
}
styles={
{
"dropdownItem": {
"fontSize": 12,
},
"dropdownItemDisabled": {
"fontSize": 12,
},
"dropdownItemSelected": {
"fontSize": 12,
},
"title": {
"height": 27,
"lineHeight": 27,
},
}
}
/>
<Separator
className="panelSeparator"
/>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
* 
</span>
<Text
className="panelTextBold"
variant="small"
>
Global secondary index container id
</Text>
</Stack>
<input
aria-required={true}
autoComplete="off"
className="panelTextField"
id="globalSecondaryIndexId"
onChange={[Function]}
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="e.g., indexbyEmailId"
required={true}
size={40}
title="May not end with space nor contain characters '\\' '/' '#' '?'"
type="text"
/>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
* 
</span>
<Text
className="panelTextBold"
variant="small"
>
Global secondary index definition
</Text>
<StyledTooltipHostBase
content={
<StyledLinkBase
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
Learn more about defining global secondary indexes.
</StyledLinkBase>
}
directionalHint={4}
>
<Icon
className="panelInfoIcon"
iconName="Info"
role="button"
tabIndex={0}
/>
</StyledTooltipHostBase>
</Stack>
<input
aria-required={true}
autoComplete="off"
className="panelTextField"
id="globalSecondaryIndexDefinition"
onChange={[Function]}
placeholder="SELECT c.email, c.accountId FROM c"
required={true}
size={40}
type="text"
value=""
/>
<PartitionKeyComponent
partitionKey=""
setPartitionKey={[Function]}
setSubPartitionKeys={[Function]}
subPartitionKeys={[]}
/>
<ThroughputComponent
globalSecondaryIndexThroughputOnChange={[Function]}
isCostAknowledgedOnChange={[Function]}
isGlobalSecondaryIndexAutoscaleOnChange={[Function]}
isSelectedSourceContainerSharedThroughput={[Function]}
setEnabledDedicatedThroughput={[Function]}
setIsThroughputCapExceeded={[Function]}
showCollectionThroughputInput={[Function]}
/>
<UniqueKeysComponent
setUniqueKeys={[Function]}
uniqueKeys={[]}
/>
<AnalyticalStoreComponent
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
setEnableAnalyticalStore={[Function]}
/>
<AdvancedComponent
setSubPartitionKeys={[Function]}
setUseHashV1={[Function]}
/>
</Stack>
</div>
<PanelFooterComponent
buttonLabel="OK"
/>
</form>
`;

View File

@@ -7,7 +7,6 @@ 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";
@@ -203,8 +202,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true}
autoComplete="off"
styles={getTextFieldStyles()}
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
pattern="[^/?#\\-]*[^/?#- \\]"
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
placeholder="Type a new keyspace id"
size={40}
value={newKeyspaceId}
@@ -293,8 +292,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true}
ariaLabel="addCollection-table Id Create table"
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
pattern="[^/?#\\-]*[^/?#- \\]"
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
placeholder="Enter table Id"
size={20}
value={tableId}

View File

@@ -28,7 +28,6 @@ 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";
@@ -236,8 +235,8 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
aria-required
required
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"

View File

@@ -94,7 +94,6 @@
padding-left: @MediumSpace;
.paneErrorLink {
color: @LinkColor;
cursor: pointer;
font-size: @mediumFontSize;
}

View File

@@ -6,9 +6,7 @@ import {
Checkbox,
ChoiceGroup,
DefaultButton,
Dropdown,
IChoiceGroupOption,
IDropdownOption,
ISpinButtonStyles,
IToggleStyles,
Position,
@@ -23,15 +21,7 @@ import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric } from "Platform/Fabric/FabricUtil";
import {
AppStateComponentNames,
deleteAllStates,
deleteState,
hasState,
loadState,
saveState,
} from "Shared/AppStatePersistenceUtility";
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
import {
DefaultRUThreshold,
LocalStorageUtility,
@@ -42,12 +32,10 @@ import {
} from "Shared/StorageUtility";
import * as StringUtility from "Shared/StringUtility";
import { updateUserContext, userContext } from "UserContext";
import { isDataplaneRbacSupported } from "Utils/APITypeUtils";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react";
@@ -154,17 +142,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
? LocalStorageUtility.getEntryString(StorageKey.IsGraphAutoVizDisabled)
: "false",
);
const [selectedRegionalEndpoint, setSelectedRegionalEndpoint] = useState<string>(
hasState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
})
? (loadState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
}) as string)
: undefined,
);
const [retryAttempts, setRetryAttempts] = useState<number>(
LocalStorageUtility.hasItem(StorageKey.RetryAttempts)
? LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts)
@@ -206,49 +183,11 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin" && !isEmulator;
const shouldShowParallelismOption = userContext.apiType !== "Gremlin" && !isEmulator;
const showEnableEntraIdRbac =
isDataplaneRbacSupported(userContext.apiType) &&
userContext.apiType === "SQL" &&
userContext.authType === AuthType.AAD &&
configContext.platform !== Platform.Fabric &&
!isEmulator;
const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled() && !isEmulator;
const uniqueAccountRegions = new Set<string>();
const regionOptions: IDropdownOption[] = [];
regionOptions.push({
key: userContext?.databaseAccount?.properties?.documentEndpoint,
text: `Global (Default)`,
data: {
isGlobal: true,
writeEnabled: true,
},
});
userContext?.databaseAccount?.properties?.writeLocations?.forEach((loc) => {
if (!uniqueAccountRegions.has(loc.locationName)) {
uniqueAccountRegions.add(loc.locationName);
regionOptions.push({
key: loc.documentEndpoint,
text: `${loc.locationName} (Read/Write)`,
data: {
isGlobal: false,
writeEnabled: true,
},
});
}
});
userContext?.databaseAccount?.properties?.readLocations?.forEach((loc) => {
if (!uniqueAccountRegions.has(loc.locationName)) {
uniqueAccountRegions.add(loc.locationName);
regionOptions.push({
key: loc.documentEndpoint,
text: `${loc.locationName} (Read)`,
data: {
isGlobal: false,
writeEnabled: false,
},
});
}
});
const shouldShowCopilotSampleDBOption =
userContext.apiType === "SQL" &&
useQueryCopilot.getState().copilotEnabled &&
@@ -334,46 +273,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
}
}
const storedRegionalEndpoint = loadState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
}) as string;
const selectedRegionIsGlobal =
selectedRegionalEndpoint === userContext?.databaseAccount?.properties?.documentEndpoint;
if (selectedRegionIsGlobal && storedRegionalEndpoint) {
deleteState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
});
updateUserContext({
selectedRegionalEndpoint: undefined,
writeEnabledInSelectedRegion: true,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
} else if (
selectedRegionalEndpoint &&
!selectedRegionIsGlobal &&
selectedRegionalEndpoint !== storedRegionalEndpoint
) {
saveState(
{
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
},
selectedRegionalEndpoint,
);
const validWriteEndpoint = userContext.databaseAccount?.properties?.writeLocations?.find(
(loc) => loc.documentEndpoint === selectedRegionalEndpoint,
);
updateUserContext({
selectedRegionalEndpoint: selectedRegionalEndpoint,
writeEnabledInSelectedRegion: !!validWriteEndpoint,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: !!validWriteEndpoint });
}
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
LocalStorageUtility.setEntryNumber(StorageKey.RetryAttempts, retryAttempts);
@@ -523,10 +422,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
setDefaultQueryResultsView(option.key as SplitterDirection);
};
const handleOnSelectedRegionOptionChange = (ev: React.FormEvent<HTMLInputElement>, option: IDropdownOption): void => {
setSelectedRegionalEndpoint(option.key as string);
};
const handleOnQueryRetryAttemptsSpinButtonChange = (ev: React.MouseEvent<HTMLElement>, newValue?: string): void => {
const retryAttempts = Number(newValue);
if (!isNaN(retryAttempts)) {
@@ -687,39 +582,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && (
<AccordionItem value="3">
<AccordionHeader>
<div className={styles.header}>Region Selection</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Changes region the Cosmos Client uses to access account.
</div>
<div>
<span className={styles.subHeader}>Select Region</span>
<InfoTooltip className={styles.headerIcon}>
Changes the account endpoint used to perform client operations.
</InfoTooltip>
</div>
<Dropdown
placeholder={
selectedRegionalEndpoint
? regionOptions.find((option) => option.key === selectedRegionalEndpoint)?.text
: regionOptions[0]?.text
}
onChange={handleOnSelectedRegionOptionChange}
options={regionOptions}
styles={{ root: { marginBottom: "10px" } }}
/>
</div>
</AccordionPanel>
</AccordionItem>
)}
{userContext.apiType === "SQL" && !isEmulator && (
<>
<AccordionItem value="4">
<AccordionItem value="3">
<AccordionHeader>
<div className={styles.header}>Query Timeout</div>
</AccordionHeader>
@@ -760,7 +625,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
<AccordionItem value="5">
<AccordionItem value="4">
<AccordionHeader>
<div className={styles.header}>RU Limit</div>
</AccordionHeader>
@@ -794,7 +659,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
<AccordionItem value="6">
<AccordionItem value="5">
<AccordionHeader>
<div className={styles.header}>Default Query Results View</div>
</AccordionHeader>
@@ -815,9 +680,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
</>
)}
{showRetrySettings && (
<AccordionItem value="7">
<AccordionItem value="6">
<AccordionHeader>
<div className={styles.header}>Retry Settings</div>
</AccordionHeader>
@@ -890,7 +754,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
)}
{!isEmulator && (
<AccordionItem value="8">
<AccordionItem value="7">
<AccordionHeader>
<div className={styles.header}>Enable container pagination</div>
</AccordionHeader>
@@ -914,7 +778,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
)}
{shouldShowCrossPartitionOption && (
<AccordionItem value="9">
<AccordionItem value="8">
<AccordionHeader>
<div className={styles.header}>Enable cross-partition query</div>
</AccordionHeader>
@@ -939,7 +803,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
)}
{shouldShowParallelismOption && (
<AccordionItem value="10">
<AccordionItem value="9">
<AccordionHeader>
<div className={styles.header}>Max degree of parallelism</div>
</AccordionHeader>
@@ -972,7 +836,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
)}
{shouldShowPriorityLevelOption && (
<AccordionItem value="11">
<AccordionItem value="10">
<AccordionHeader>
<div className={styles.header}>Priority Level</div>
</AccordionHeader>
@@ -995,7 +859,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
)}
{shouldShowGraphAutoVizOption && (
<AccordionItem value="12">
<AccordionItem value="11">
<AccordionHeader>
<div className={styles.header}>Display Gremlin query results as:&nbsp;</div>
</AccordionHeader>
@@ -1016,7 +880,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
)}
{shouldShowCopilotSampleDBOption && (
<AccordionItem value="13">
<AccordionItem value="12">
<AccordionHeader>
<div className={styles.header}>Enable sample database</div>
</AccordionHeader>
@@ -1051,15 +915,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
"Clear History",
undefined,
"Are you sure you want to proceed?",
() => {
deleteAllStates();
updateUserContext({
selectedRegionalEndpoint: undefined,
writeEnabledInSelectedRegion: true,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
},
() => deleteAllStates(),
"Cancel",
undefined,
<>
@@ -1070,7 +926,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<li>Reset your customized tab layout, including the splitter positions</li>
<li>Erase your table column preferences, including any custom columns</li>
<li>Clear your filter history</li>
<li>Reset region selection to global</li>
</ul>
</>,
);

View File

@@ -107,7 +107,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="4"
value="3"
>
<AccordionHeader>
<div
@@ -148,7 +148,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="5"
value="4"
>
<AccordionHeader>
<div
@@ -219,7 +219,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="6"
value="5"
>
<AccordionHeader>
<div
@@ -281,7 +281,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="7"
value="6"
>
<AccordionHeader>
<div
@@ -423,7 +423,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="8"
value="7"
>
<AccordionHeader>
<div
@@ -459,7 +459,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="9"
value="8"
>
<AccordionHeader>
<div
@@ -495,7 +495,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="10"
value="9"
>
<AccordionHeader>
<div
@@ -575,7 +575,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
className="customAccordion ___1uf6361_0000000 fz7g6wx"
>
<AccordionItem
value="7"
value="6"
>
<AccordionHeader>
<div
@@ -717,7 +717,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="8"
value="7"
>
<AccordionHeader>
<div
@@ -753,7 +753,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="12"
value="11"
>
<AccordionHeader>
<div

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

@@ -18,7 +18,7 @@ import { createCollection } from "Common/dataAccess/createCollection";
import * as DataModels from "Contracts/DataModels";
import { ContainerSampleGenerator } from "Explorer/DataSamples/ContainerSampleGenerator";
import Explorer from "Explorer/Explorer";
import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel";
import { PromptCard } from "Explorer/QueryCopilot/PromptCard";
import { useDatabases } from "Explorer/useDatabases";
import { useCarousel } from "hooks/useCarousel";

View File

@@ -18,7 +18,7 @@ import {
Text,
TextField,
} from "@fluentui/react";
import { FeedbackLabels, HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
import { createUri } from "Common/UrlUtility";
@@ -393,7 +393,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
},
}}
disabled={isGeneratingQuery}
autoComplete="off"
autoComplete="list"
aria-expanded={showSamplePrompts}
placeholder="Ask a question in natural language and well generate the query for you."
aria-labelledby="copilot-textfield-label"
onRenderSuffix={() => {
@@ -579,7 +580,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
<Stack horizontal verticalAlign="center" style={{ maxHeight: 20 }}>
{userContext.feedbackPolicies?.policyAllowFeedback && (
<Stack horizontal verticalAlign="center">
<Text style={{ fontSize: 12 }}>{FeedbackLabels.provideFeedback}</Text>
<Text style={{ fontSize: 12 }}>Provide feedback</Text>
{showCallout && !hideFeedbackModalForLikedQueries && (
<Callout
role="status"
@@ -629,9 +630,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
<IconButton
id="likeBtn"
style={{ marginLeft: 10 }}
aria-label={FeedbackLabels.provideFeedback}
role="button"
title="Like"
aria-label="Like"
role="toggle"
iconProps={{ iconName: likeQuery === true ? "LikeSolid" : "Like" }}
onClick={() => {
setShowCallout(!likeQuery);
@@ -649,9 +649,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
/>
<IconButton
style={{ margin: "0 4px" }}
role="button"
aria-label={FeedbackLabels.provideFeedback}
title="Dislike"
role="toggle"
aria-label="Dislike"
iconProps={{ iconName: dislikeQuery === true ? "DislikeSolid" : "Dislike" }}
onClick={() => {
let toggleStatusValue = "Unpressed";

View File

@@ -1,6 +1,5 @@
import {
Button,
makeStyles,
Menu,
MenuButton,
MenuButtonProps,
@@ -8,26 +7,20 @@ import {
MenuList,
MenuPopover,
MenuTrigger,
SplitButton,
makeStyles,
mergeClasses,
shorthands,
SplitButton,
} from "@fluentui/react-components";
import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext";
import { Platform, configContext } from "ConfigContext";
import Explorer from "Explorer/Explorer";
import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { Tabs } from "Explorer/Tabs/Tabs";
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { ResourceTree } from "Explorer/Tree/ResourceTree";
import { useDatabases } from "Explorer/useDatabases";
import { KeyboardAction, KeyboardActionGroup, KeyboardActionHandler, useKeyboardActionGroup } from "KeyboardShortcuts";
import { isFabric, isFabricMirrored, isFabricNative } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext";
import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
import { Allotment, AllotmentHandle } from "allotment";
@@ -130,7 +123,7 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
const actions = useMemo<GlobalCommand[]>(() => {
if (
(isFabric() && userContext.fabricContext?.isReadOnly) ||
configContext.platform === Platform.Fabric ||
userContext.apiType === "Postgres" ||
userContext.apiType === "VCoreMongo"
) {
@@ -144,15 +137,12 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
id: "new_collection",
label: `New ${getCollectionName()}`,
icon: <Add16Regular />,
onClick: () => {
const databaseId = isFabricNative() ? userContext.fabricContext?.databaseName : undefined;
explorer.onNewCollectionClicked({ databaseId });
},
onClick: () => explorer.onNewCollectionClicked(),
keyboardAction: KeyboardAction.NEW_COLLECTION,
},
];
if (configContext.platform !== Platform.Fabric && userContext.apiType !== "Tables") {
if (userContext.apiType !== "Tables") {
actions.push({
id: "new_database",
label: `New ${getDatabaseName()}`,
@@ -168,25 +158,6 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
});
}
if (isGlobalSecondaryIndexEnabled()) {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer,
};
actions.push({
id: "new_materialized_view",
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
icon: <Add16Regular />,
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
),
});
}
return actions;
}, [explorer]);
@@ -317,7 +288,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
}, [setLoading]);
const hasGlobalCommands = !(
isFabricMirrored() ||
configContext.platform === Platform.Fabric ||
userContext.apiType === "Postgres" ||
userContext.apiType === "VCoreMongo"
);
@@ -340,18 +311,16 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
<>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
{!isFabricNative() && (
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
)}
<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,185 +0,0 @@
/**
* Accordion top class
*/
import { Link, makeStyles, tokens } from "@fluentui/react-components";
import { DocumentAddRegular, LinkMultipleRegular } from "@fluentui/react-icons";
import { SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import * 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 {
explorer: Explorer;
}
const useStyles = makeStyles({
homeContainer: {
width: "100%",
alignContent: "center",
},
title: {
textAlign: "center",
fontSize: "20px",
fontWeight: "bold",
},
buttonsContainer: {
width: "584px",
margin: "auto",
display: "grid",
padding: "16px",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "10px",
gridAutoRows: "minmax(184px, auto)",
},
one: {
gridColumn: "1 / 3",
gridRow: "1 / 3",
"& svg": {
width: "48px",
height: "48px",
margin: "auto",
},
},
two: {
gridColumn: "3",
gridRow: "1",
"& img": {
width: "32px",
height: "32px",
margin: "auto",
},
},
three: {
gridColumn: "3",
gridRow: "2",
"& svg": {
width: "32px",
height: "32px",
margin: "auto",
},
},
buttonContainer: {
height: "100%",
display: "flex",
flexDirection: "column",
border: "1px solid #e0e0e0",
cursor: "pointer",
"&:hover": {
backgroundColor: tokens.colorNeutralBackground1Hover,
"border-color": tokens.colorNeutralStroke1Hover,
},
},
buttonUpperPart: {
textAlign: "center",
flexGrow: 1,
display: "flex",
backgroundColor: "#e3f7ef",
},
buttonLowerPart: {
borderTop: "1px solid #e0e0e0",
height: "76px",
padding: "8px",
"> div:nth-child(1)": {
fontWeight: "bold",
},
display: "flex",
flexDirection: "column",
justifyContent: "center",
},
footer: {
textAlign: "center",
},
});
interface FabricHomeScreenButtonProps {
title: string;
description: string;
icon: JSX.Element;
onClick?: () => void;
}
const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className: string }> = ({
title,
description,
icon,
className,
onClick,
}) => {
const styles = useStyles();
return (
<div role="button" className={`${styles.buttonContainer} ${className}`} onClick={onClick}>
<div className={styles.buttonUpperPart}>{icon}</div>
<div aria-label={title} className={styles.buttonLowerPart}>
<div>{title}</div>
<div>{description}</div>
</div>
</div>
);
};
export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScreenProps) => {
const styles = useStyles();
const [openSampleDataImportDialog, setOpenSampleDataImportDialog] = React.useState(false);
const getSplashScreenButtons = (): JSX.Element => {
const buttons: FabricHomeScreenButtonProps[] = [
{
title: "New container",
description: "Create a destination container to store your data",
icon: <DocumentAddRegular />,
onClick: () => {
const databaseId = isFabricNative() ? userContext.fabricContext?.databaseName : undefined;
props.explorer.onNewCollectionClicked({ databaseId });
},
},
{
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 (
<div className={styles.buttonsContainer}>
<FabricHomeScreenButton className={styles.one} {...buttons[0]} />
<FabricHomeScreenButton className={styles.two} {...buttons[1]} />
<FabricHomeScreenButton className={styles.three} {...buttons[2]} />
</div>
);
};
const title = "Build your database";
return (
<>
<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

@@ -1,158 +0,0 @@
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

@@ -1,56 +0,0 @@
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,
};
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;
};
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

@@ -0,0 +1,126 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*/
import { IDisposable, ITerminalAddon, Terminal } from 'xterm';
interface IAttachOptions {
bidirectional?: boolean;
}
export class AttachAddon implements ITerminalAddon {
private _socket: WebSocket;
private _bidirectional: boolean;
private _disposables: IDisposable[] = [];
private _socketData: string;
constructor(socket: WebSocket, options?: IAttachOptions) {
this._socket = socket;
// always set binary type to arraybuffer, we do not handle blobs
this._socket.binaryType = 'arraybuffer';
this._bidirectional = !(options && options.bidirectional === false);
this._socketData = '';
}
public activate(terminal: Terminal): void {
this._disposables.push(
addSocketListener(this._socket, 'message', ev => {
let data: ArrayBuffer | string = ev.data;
const startStatusJson = 'ie_us';
const endStatusJson = 'ie_ue';
if (typeof data === 'object') {
const enc = new TextDecoder("utf-8");
data = enc.decode(ev.data as any);
}
// for example of json object look in TerminalHelper in the socket.onMessage
if (data.includes(startStatusJson) && data.includes(endStatusJson)) {
// process as one line
const statusData = data.split(startStatusJson)[1].split(endStatusJson)[0];
data = data.replace(statusData, '');
data = data.replace(startStatusJson, '');
data = data.replace(endStatusJson, '');
} else if (data.includes(startStatusJson)) {
// check for start
const partialStatusData = data.split(startStatusJson)[1];
this._socketData += partialStatusData;
data = data.replace(partialStatusData, '');
data = data.replace(startStatusJson, '');
} else if (data.includes(endStatusJson)) {
// check for end and process the command
const partialStatusData = data.split(endStatusJson)[0];
this._socketData += partialStatusData;
data = data.replace(partialStatusData, '');
data = data.replace(endStatusJson, '');
this._socketData = '';
} else if (this._socketData.length > 0) {
// check if the line is all data then just concatenate
this._socketData += data;
data = '';
}
terminal.write(data);
})
);
if (this._bidirectional) {
this._disposables.push(terminal.onData(data => this._sendData(data)));
this._disposables.push(terminal.onBinary(data => this._sendBinary(data)));
}
this._disposables.push(addSocketListener(this._socket, 'close', () => this.dispose()));
this._disposables.push(addSocketListener(this._socket, 'error', () => this.dispose()));
}
public dispose(): void {
for (const d of this._disposables) {
d.dispose();
}
}
private _sendData(data: string): void {
if (!this._checkOpenSocket()) {
return;
}
this._socket.send(data);
}
private _sendBinary(data: string): void {
if (!this._checkOpenSocket()) {
return;
}
const buffer = new Uint8Array(data.length);
for (let i = 0; i < data.length; ++i) {
buffer[i] = data.charCodeAt(i) & 255;
}
this._socket.send(buffer);
}
private _checkOpenSocket(): boolean {
switch (this._socket.readyState) {
case WebSocket.OPEN:
return true;
case WebSocket.CONNECTING:
throw new Error('Attach addon was loaded before socket was open');
case WebSocket.CLOSING:
return false;
case WebSocket.CLOSED:
throw new Error('Attach addon socket is closed');
default:
throw new Error('Unexpected socket state');
}
}
}
function addSocketListener<K extends keyof WebSocketEventMap>(socket: WebSocket, type: K, handler: (this: WebSocket, ev: WebSocketEventMap[K]) => any): IDisposable {
socket.addEventListener(type, handler);
return {
dispose: () => {
if (!handler) {
// Already disposed
return;
}
socket.removeEventListener(type, handler);
}
};
}

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useRef } from "react";
import { Terminal } from "xterm";
import { FitAddon } from 'xterm-addon-fit';
import "xterm/css/xterm.css";
import { TerminalKind } from "../../../Contracts/ViewModels";
import { startCloudShellTerminal } from "./Core/CloudShellTerminalCore";
export interface CloudShellTerminalProps {
shellType: TerminalKind;
}
export const CloudShellTerminalComponent: React.FC<CloudShellTerminalProps> = ({
shellType
}: CloudShellTerminalProps) => {
const terminalRef = useRef(null); // Reference for terminal container
const xtermRef = useRef(null); // Reference for XTerm instance
const socketRef = useRef(null); // Reference for WebSocket
const fitAddon = new FitAddon();
useEffect(() => {
// Initialize XTerm instance
const term = new Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontFamily: 'monospace',
fontSize: 14,
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#ffcc00"
},
scrollback: 1000
});
term.loadAddon(fitAddon);
// Attach terminal to the DOM
if (terminalRef.current) {
term.open(terminalRef.current);
xtermRef.current = term;
}
if (fitAddon) {
fitAddon.fit();
}
// Adjust terminal size on window resize
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
try {
socketRef.current = startCloudShellTerminal(term, shellType);
term.onData((data) => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(data);
}
});
} catch (error) {
console.error("Failed to initialize CloudShell terminal:", error);
term.writeln(`\x1B[31mError initializing terminal: ${error.message}\x1B[0m`);
}
// Cleanup function to close WebSocket and dispose terminal
return () => {
if (!socketRef.current) return;
if (socketRef.current) {
socketRef.current.close(); // Close WebSocket connection
}
window.removeEventListener('resize', handleResize);
term.dispose(); // Clean up XTerm instance
};
}, [shellType]);
return <div ref={terminalRef} style={{ width: "100%", height: "500px"}} />;
};

View File

@@ -0,0 +1,152 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*/
import { TerminalKind } from "../../../Contracts/ViewModels";
import { userContext } from "../../../UserContext";
export const getCommands = (terminalKind: TerminalKind, key: string) => {
let dbAccount = userContext.databaseAccount;
let endpoint;
switch (terminalKind) {
case TerminalKind.Postgres:
endpoint = dbAccount.properties.postgresqlEndpoint;
break;
case TerminalKind.Mongo:
endpoint = dbAccount.properties.mongoEndpoint;
break;
case TerminalKind.VCoreMongo:
endpoint = dbAccount.properties.vcoreMongoEndpoint;
break;
case TerminalKind.Cassandra:
endpoint = dbAccount.properties.cassandraEndpoint;
break;
default:
throw new Error("Unknown Terminal Kind");
}
let config = {
host: getHostFromUrl(endpoint),
name: dbAccount.name,
password: key,
endpoint: endpoint
};
return commands(terminalKind, config).join("\n").concat("\n");
};
export interface CommandConfig {
host: string,
name: string,
password: string,
endpoint: string
}
export const commands = (terminalKind: TerminalKind, config?: CommandConfig): string[] => {
switch (terminalKind) {
case TerminalKind.Postgres:
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if psql is installed; if not, proceed with installation
"if ! command -v psql &> /dev/null; then echo '⚠️ psql not found. Installing...'; fi",
// 3. Download PostgreSQL if not installed
"if ! command -v psql &> /dev/null; then curl -LO https://ftp.postgresql.org/pub/source/v15.2/postgresql-15.2.tar.bz2; fi",
// 4. Extract PostgreSQL package if not installed
"if ! command -v psql &> /dev/null; then tar -xvjf postgresql-15.2.tar.bz2; fi",
// 5. Create a directory for PostgreSQL installation if not installed
"if ! command -v psql &> /dev/null; then mkdir -p ~/pgsql; fi",
// 6. Download readline (dependency for PostgreSQL) if not installed
"if ! command -v psql &> /dev/null; then curl -LO https://ftp.gnu.org/gnu/readline/readline-8.1.tar.gz; fi",
// 7. Extract readline package if not installed
"if ! command -v psql &> /dev/null; then tar -xvzf readline-8.1.tar.gz; fi",
// 8. Configure readline if not installed
"if ! command -v psql &> /dev/null; then cd readline-8.1 && ./configure --prefix=$HOME/pgsql; fi",
// 9. Add PostgreSQL to PATH if not installed
"if ! command -v psql &> /dev/null; then echo 'export PATH=$HOME/pgsql/bin:$PATH' >> ~/.bashrc; fi",
// 10. Source .bashrc to update PATH (even if psql was already installed)
"source ~/.bashrc",
// 11. Verify PostgreSQL installation
"psql --version",
`psql 'read -p "Enter Database Name: " dbname && read -p "Enter Username: " username && host=${config.endpoint} port=5432 dbname=$dbname user=$username sslmode=require'`
];
case TerminalKind.Mongo:
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if mongosh is installed; if not, proceed with installation
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
// 3. Download mongosh if not installed
"if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz; fi",
// 4. Extract mongosh package if not installed
"if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-2.3.8-linux-x64.tgz; fi",
// 5. Move mongosh binaries if not installed
"if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/; fi",
// 6. Add mongosh to PATH if not installed
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
// 7. Source .bashrc to update PATH (even if mongosh was already installed)
"source ~/.bashrc",
// 8. Verify mongosh installation
"mongosh --version",
// 9. Login to MongoDB
`mongosh --host ${config.host} --port 10255 --username ${config.name} --password ${config.password} --tls --tlsAllowInvalidCertificates`
];
case TerminalKind.VCoreMongo:
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if mongosh is installed; if not, proceed with installation
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
// 3. Download mongosh if not installed
"if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz; fi",
// 4. Extract mongosh package if not installed
"if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-2.3.8-linux-x64.tgz; fi",
// 5. Move mongosh binaries if not installed
"if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/; fi",
// 6. Add mongosh to PATH if not installed
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
// 7. Source .bashrc to update PATH (even if mongosh was already installed)
"source ~/.bashrc",
// 8. Verify mongosh installation
"mongosh --version",
// 10. Login to MongoDBmongosh mongodb+srv://<credentials>@neesharma-stage-mongo-vcore.mongocluster.cosmos.azure.com/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000\u0007
`read -p "Enter username: " username && mongosh "mongodb+srv://$username:@${config.endpoint}/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000" --tls --tlsAllowInvalidCertificates`
];
case TerminalKind.Cassandra:
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if cqlsh is installed; if not, proceed with installation
"if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi",
// 3. Download Cassandra if not installed
"if ! command -v cqlsh &> /dev/null; then curl -LO https://archive.apache.org/dist/cassandra/5.0.3/apache-cassandra-5.0.3-bin.tar.gz; fi",
// 4. Extract Cassandra package if not installed
"if ! command -v cqlsh &> /dev/null; then tar -xvzf apache-cassandra-5.0.3-bin.tar.gz; fi",
// 5. Move Cassandra binaries if not installed
"if ! command -v cqlsh &> /dev/null; then mkdir -p ~/cassandra && mv apache-cassandra-5.0.3/* ~/cassandra/; fi",
// 6. Add Cassandra to PATH if not installed
"if ! command -v cqlsh &> /dev/null; then echo 'export PATH=$HOME/cassandra/bin:$PATH' >> ~/.bashrc; fi",
// 7. Set environment variables for SSL
"if ! command -v cqlsh &> /dev/null; then echo 'export SSL_VERSION=TLSv1_2' >> ~/.bashrc; fi",
"if ! command -v cqlsh &> /dev/null; then echo 'export SSL_VALIDATE=false' >> ~/.bashrc; fi",
// 8. Source .bashrc to update PATH (even if cqlsh was already installed)
"source ~/.bashrc",
// 9. Verify cqlsh installation
"cqlsh --version",
// 10. Login to Cassandra
`cqlsh ${config.host} 10350 -u ${config.name} -p ${config.password} --ssl --protocol-version=4`
];
default:
return ["echo Unknown Shell"];
}
}
const getHostFromUrl = (mongoEndpoint: string): string => {
try {
const url = new URL(mongoEndpoint);
return url.hostname;
} catch (error) {
console.error("Invalid Mongo Endpoint URL:", error);
return "";
}
};

View File

@@ -0,0 +1,393 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Core functionality for CloudShell terminal management
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import {
authorizeSession,
connectTerminal,
provisionConsole,
putEphemeralUserSettings,
registerCloudShellProvider,
verifyCloudShellProviderRegistration
} from "../Data/CloudShellApiClient";
import { getNormalizedRegion } from "../Data/RegionUtils";
import { ShellTypeHandler } from "../ShellTypes/ShellTypeFactory";
import { AttachAddon } from "../Utils/AttachAddOn";
import { wait } from "../Utils/CommonUtils";
import { terminalLog } from "../Utils/LogFormatter";
// Constants
const DEFAULT_CLOUDSHELL_REGION = "westus";
const POLLING_INTERVAL_MS = 5000;
const MAX_RETRY_COUNT = 10;
const MAX_PING_COUNT = 20 * 60; // 20 minutes (60 seconds/minute)
/**
* Main function to start a CloudShell terminal
*/
export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => {
// Get the shell handler for this type
const shellHandler = ShellTypeHandler.getHandler(shellType);
terminal.writeln(terminalLog.header("Initializing Azure CloudShell"));
await ensureCloudShellProviderRegistered(terminal);
const { resolvedRegion, defaultCloudShellRegion } = determineCloudShellRegion(terminal);
// Ask for user consent for region
const consentGranted = await askForRegionConsent(terminal, resolvedRegion);
if (!consentGranted) {
return {}; // Exit if user declined
}
// Check network requirements for this shell type
const networkConfig = await shellHandler.configureNetworkAccess(terminal, resolvedRegion);
terminal.writeln("");
// Provision CloudShell session
terminal.writeln(terminalLog.cloudshell(`Provisioning Started....`));
let sessionDetails: {
socketUri?: string;
provisionConsoleResponse?: any;
targetUri?: string;
};
try {
sessionDetails = await provisionCloudShellSession(resolvedRegion, terminal, networkConfig.vNetSettings, networkConfig.isAllPublicAccessEnabled);
} catch (err) {
terminal.writeln(terminalLog.error(err));
terminal.writeln(terminalLog.error("Failed to provision in primary region"));
terminal.writeln(terminalLog.warning(`Attempting with fallback region: ${defaultCloudShellRegion}`));
sessionDetails = await provisionCloudShellSession(defaultCloudShellRegion, terminal, networkConfig.vNetSettings, networkConfig.isAllPublicAccessEnabled);
}
if (!sessionDetails.socketUri) {
terminal.writeln(terminalLog.error('Unable to provision console. Please try again later.'));
return {};
}
// Configure WebSocket connection with shell-specific commands
const socket = await establishTerminalConnection(
terminal,
shellHandler,
sessionDetails.socketUri,
sessionDetails.provisionConsoleResponse,
sessionDetails.targetUri
);
return socket;
};
/**
* Ensures that the CloudShell provider is registered for the current subscription
*/
export const ensureCloudShellProviderRegistered = async (terminal: Terminal): Promise<void> => {
try {
terminal.writeln(terminalLog.info("Verifying provider registration..."));
const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId);
if (response.registrationState !== "Registered") {
terminal.writeln(terminalLog.warning("Registering CloudShell provider..."));
await registerCloudShellProvider(userContext.subscriptionId);
terminal.writeln(terminalLog.success("Provider registration successful"));
}
} catch (err) {
terminal.writeln(terminalLog.error("Unable to verify provider registration"));
throw err;
}
};
/**
* Determines the appropriate CloudShell region
*/
export const determineCloudShellRegion = (terminal: Terminal): { resolvedRegion: string; defaultCloudShellRegion: string } => {
const region = userContext.databaseAccount?.location;
const resolvedRegion = getNormalizedRegion(region, DEFAULT_CLOUDSHELL_REGION);
return { resolvedRegion, defaultCloudShellRegion: DEFAULT_CLOUDSHELL_REGION };
};
/**
* Asks the user for consent to use the specified CloudShell region
*/
export const askForRegionConsent = async (terminal: Terminal, resolvedRegion: string): Promise<boolean> => {
terminal.writeln(terminalLog.header("CloudShell Region Confirmation"));
terminal.writeln(terminalLog.info("The CloudShell container will be provisioned in a specific Azure region."));
// Data residency and compliance information
terminal.writeln(terminalLog.subheader("Important Information"));
const dbRegion = userContext.databaseAccount?.location || "unknown";
terminal.writeln(terminalLog.item("Database Region", dbRegion));
terminal.writeln(terminalLog.item("CloudShell Container Region", resolvedRegion));
terminal.writeln(terminalLog.subheader("What this means to you?"));
terminal.writeln(terminalLog.item("Data Residency", "Commands and query results will be processed in this region"));
terminal.writeln(terminalLog.item("Network", "Database connections will originate from this region"));
// Consent question
terminal.writeln("");
terminal.writeln(terminalLog.prompt("Would you like to provision Azure CloudShell in the '" + resolvedRegion + "' region?"));
terminal.writeln(terminalLog.prompt("Press 'Y' to continue or 'N' to cancel (Y/N)"));
return new Promise<boolean>((resolve) => {
const keyListener = terminal.onKey(({ key }: { key: string }) => {
keyListener.dispose();
terminal.writeln("");
if (key.toLowerCase() === 'y') {
terminal.writeln(terminalLog.success("Proceeding with CloudShell in " + resolvedRegion));
terminal.writeln(terminalLog.separator());
resolve(true);
} else {
terminal.writeln(terminalLog.error("CloudShell provisioning canceled"));
setTimeout(() => terminal.dispose(), 2000);
resolve(false);
}
});
});
};
/**
* Provisions a CloudShell session
*/
export const provisionCloudShellSession = async (
resolvedRegion: string,
terminal: Terminal,
vNetSettings: object,
isAllPublicAccessEnabled: boolean
): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => {
return new Promise( async (resolve, reject) => {
try {
terminal.writeln(terminalLog.header("Configuring CloudShell Session"));
// Check if vNetSettings is available and not empty
const hasVNetSettings = vNetSettings && Object.keys(vNetSettings).length > 0;
if (hasVNetSettings) {
terminal.writeln(terminalLog.vnet("Enabling private network configuration"));
displayNetworkSettings(terminal, vNetSettings, resolvedRegion);
}
else {
terminal.writeln(terminalLog.warning("No VNet configuration provided"));
terminal.writeln(terminalLog.warning("CloudShell will be provisioned with public network access"));
if (!isAllPublicAccessEnabled) {
terminal.writeln(terminalLog.error("Warning: Your database has network restrictions"));
terminal.writeln(terminalLog.error("CloudShell may not be able to connect without proper VNet configuration"));
}
}
terminal.writeln(terminalLog.warning("Any previous VNet settings will be overridden"));
// Apply user settings
await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings);
terminal.writeln(terminalLog.success("Session settings applied"));
// Provision console
let provisionConsoleResponse;
let attemptCounter = 0;
do {
provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, resolvedRegion);
terminal.writeln(terminalLog.progress("Provisioning", provisionConsoleResponse.properties.provisioningState));
attemptCounter++;
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
await wait(POLLING_INTERVAL_MS);
}
} while (provisionConsoleResponse.properties.provisioningState !== "Succeeded" && attemptCounter < 10);
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
const errorMessage = `Provisioning failed: ${provisionConsoleResponse.properties.provisioningState}`;
terminal.writeln(terminalLog.error(errorMessage));
return reject(new Error(errorMessage));
}
// 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];
if (socketUri.indexOf(targetUriBody) === -1) {
socketUri = `wss://${targetUriBody}/${termId}`;
}
if (targetUriBody.includes('servicebus')) {
const targetUriBodyArr = targetUriBody.split('/');
socketUri = `wss://${targetUriBodyArr[0]}/$hc/${targetUriBodyArr[1]}/terminals/${termId}`;
}
return resolve({ socketUri, provisionConsoleResponse, targetUri });
} catch (err) {
terminal.writeln(terminalLog.error(`Provisioning failed: ${err.message}`));
return reject(err);
}
});
};
/**
* Display VNet settings in the terminal
*/
const displayNetworkSettings = (terminal: Terminal, vNetSettings: any, resolvedRegion: string): void => {
if (vNetSettings.networkProfileResourceId) {
const profileName = vNetSettings.networkProfileResourceId.split('/').pop();
terminal.writeln(terminalLog.item("Network Profile", profileName));
if (vNetSettings.relayNamespaceResourceId) {
const relayName = vNetSettings.relayNamespaceResourceId.split('/').pop();
terminal.writeln(terminalLog.item("Relay Namespace", relayName));
}
terminal.writeln(terminalLog.item("Region", resolvedRegion));
terminal.writeln(terminalLog.success("CloudShell will use this VNet to connect to your database"));
}
};
/**
* Establishes a terminal connection via WebSocket
*/
export const establishTerminalConnection = async (
terminal: Terminal,
shellHandler: any,
socketUri: string,
provisionConsoleResponse: any,
targetUri: string
): Promise<WebSocket> => {
let socket = new WebSocket(socketUri);
// Get shell-specific initial commands
const initCommands = await shellHandler.getInitialCommands();
// Configure the socket
socket = configureSocketConnection(socket, socketUri, terminal, initCommands, 0);
// Attach the terminal addon
const attachAddon = new AttachAddon(socket);
terminal.loadAddon(attachAddon);
terminal.writeln(terminalLog.success("Connection established"));
// Authorize the session
try {
const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri);
const cookieToken = authorizeResponse.token;
// Load auth token with a hidden image
const img = document.createElement("img");
img.src = `${targetUri}&token=${encodeURIComponent(cookieToken)}`;
terminal.focus();
} catch (err) {
terminal.writeln(terminalLog.error("Authorization failed"));
socket.close();
throw err;
}
return socket;
};
/**
* Configures a WebSocket connection for the terminal
*/
export const configureSocketConnection = (
socket: WebSocket,
uri: string,
terminal: Terminal,
initCommands: string,
socketRetryCount: number
): WebSocket => {
let jsonData = '';
let keepAliveID: NodeJS.Timeout = null;
let pingCount = 0;
sendTerminalStartupCommands(socket, initCommands);
socket.onclose = () => {
if (keepAliveID) {
clearTimeout(keepAliveID);
pingCount = 0;
}
terminal.writeln(terminalLog.warning("Session terminated. Refresh the page to start a new session."));
};
socket.onerror = () => {
if (socketRetryCount < MAX_RETRY_COUNT && socket.readyState !== WebSocket.CLOSED) {
configureSocketConnection(socket, uri, terminal, initCommands, socketRetryCount + 1);
} else {
socket.close();
}
};
socket.onmessage = (event: MessageEvent<string>) => {
pingCount = 0; // Reset ping count on message receipt
let eventData = '';
if (typeof event.data === "object") {
try {
const enc = new TextDecoder("utf-8");
eventData = enc.decode(event.data as any);
} catch (e) {
// Not an array buffer, ignore
}
}
if (typeof event.data === 'string') {
eventData = event.data;
}
// Process event data
if (eventData.includes("ie_us") && eventData.includes("ie_ue")) {
const statusData = eventData.split('ie_us')[1].split('ie_ue')[0];
console.log(statusData);
} else if (eventData.includes("ie_us")) {
jsonData += eventData.split('ie_us')[1];
} else if (eventData.includes("ie_ue")) {
jsonData += eventData.split('ie_ue')[0];
console.log(jsonData);
jsonData = '';
} else if (jsonData.length > 0) {
jsonData += eventData;
}
};
return socket;
};
/**
* Sends startup commands to the terminal
*/
export const sendTerminalStartupCommands = (socket: WebSocket, initCommands: string): void => {
let keepAliveID: NodeJS.Timeout = null;
let pingCount = 0;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(initCommands);
} else {
socket.onopen = () => {
socket.send(initCommands);
const keepSocketAlive = (socket: WebSocket) => {
if (socket.readyState === WebSocket.OPEN) {
if (pingCount >= MAX_PING_COUNT) {
socket.close();
} else {
socket.send('');
pingCount++;
keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000);
}
}
};
keepSocketAlive(socket);
};
}
};

View File

@@ -0,0 +1,320 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*/
import { ApiVersionsConfig, ResourceType } from "Explorer/Tabs/CloudShellTab/DataModels";
import { v4 as uuidv4 } from 'uuid';
import { configContext } from "../../../ConfigContext";
import { TerminalKind } from "../../../Contracts/ViewModels";
import { userContext } from '../../../UserContext';
import { armRequest } from "../../../Utils/arm/request";
import { Authorization, ConnectTerminalResponse, NetworkType, OsType, ProvisionConsoleResponse, SessionType, Settings, ShellType } from "./DataModels";
/**
* API version configuration by terminal type and resource type
*/
const API_VERSIONS : ApiVersionsConfig = {
// Default version for fallback
DEFAULT: "2024-07-01",
// Resource type specific defaults
RESOURCE_DEFAULTS: {
[ResourceType.NETWORK]: "2023-05-01",
[ResourceType.DATABASE]: "2024-07-01",
[ResourceType.VNET]: "2023-05-01",
[ResourceType.SUBNET]: "2023-05-01",
[ResourceType.RELAY]: "2024-01-01",
[ResourceType.ROLE]: "2022-04-01"
},
// Shell-type specific versions with resource overrides
SHELL_TYPES: {
[TerminalKind.Mongo]: {
[ResourceType.DATABASE]: "2024-11-15"
},
[TerminalKind.VCoreMongo]: {
[ResourceType.DATABASE]: "2024-07-01"
},
[TerminalKind.Cassandra]: {
[ResourceType.DATABASE]: "2024-11-15"
}
}
};
export const validateUserSettings = (userSettings: Settings) => {
if (userSettings.sessionType !== SessionType.Ephemeral && userSettings.osType !== OsType.Linux) {
return false;
} else {
return true;
}
}
// Current shell type context
let currentShellType: TerminalKind | null = null;
/**
* Set the active shell type to determine API version
*/
export const setShellType = (shellType: TerminalKind): void => {
currentShellType = shellType;
};
/**
* Get the appropriate API version based on shell type and resource type
* Uses a cascading fallback approach for maximum flexibility
*/
export const getApiVersion = (resourceType?: ResourceType): string => {
// If no shell type is set, fallback to resource default or global default
if (!currentShellType) {
return resourceType ?
(API_VERSIONS.RESOURCE_DEFAULTS[resourceType] || API_VERSIONS.DEFAULT) :
API_VERSIONS.DEFAULT;
}
// Shell type is set, try to get specific version in this priority:
// 1. Shell-specific + resource-specific
if (resourceType &&
API_VERSIONS.SHELL_TYPES[currentShellType]) {
const shellTypeConfig = API_VERSIONS.SHELL_TYPES[currentShellType];
if (resourceType in shellTypeConfig) {
return shellTypeConfig[resourceType] as string;
}
}
// 2. Resource-specific default
if (resourceType && resourceType in API_VERSIONS.RESOURCE_DEFAULTS) {
return API_VERSIONS.RESOURCE_DEFAULTS[resourceType];
}
// 3. Global default
return API_VERSIONS.DEFAULT;
};
export const getUserRegion = async (subscriptionId: string, resourceGroup: string, accountName: string) => {
return await armRequest({
host: configContext.ARM_ENDPOINT,
path: `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`,
method: "GET",
apiVersion: "2022-12-01"
});
};
export const deleteUserSettings = async (): Promise<void> => {
await armRequest<void>({
host: configContext.ARM_ENDPOINT,
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
method: "DELETE",
apiVersion: "2023-02-01-preview"
});
};
export const getUserSettings = async (): Promise<Settings> => {
const resp = await armRequest<any>({
host: configContext.ARM_ENDPOINT,
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
method: "GET",
apiVersion: "2023-02-01-preview"
});
return resp;
};
export const putEphemeralUserSettings = async (userSubscriptionId: string, userRegion: string, vNetSettings?: object) => {
const ephemeralSettings = {
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) => {
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 (subscriptionId: string, location: string): Promise<ProvisionConsoleResponse> => {
const data = {
properties: {
osType: OsType.Linux
}
};
return await armRequest<any>({
host: configContext.ARM_ENDPOINT,
path: `providers/Microsoft.Portal/consoles/default`,
method: "PUT",
apiVersion: "2023-02-01-preview",
customHeaders: {
'x-ms-console-preferred-location': location
},
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
});
return resp.json();
};
export const authorizeSession = async (consoleUri: string): Promise<Authorization> => {
const targetUri = consoleUri + "/authorize";
const resp = await fetch(targetUri, {
method: "POST",
headers: {
'Accept': 'application/json',
'Authorization': userContext.authorizationToken,
'Accept-Language': getLocale(),
"Content-Type": 'application/json'
},
body: "{}" // empty body is necessary
});
return resp.json();
};
export const getLocale = () => {
const langLocale = navigator.language;
return (langLocale && langLocale.length === 2 ? langLocale[1] : 'en-us');
};
const validCloudShellRegions = new Set(["westus", "southcentralus", "eastus", "northeurope", "westeurope", "centralindia", "southeastasia", "westcentralus"]);
export const getNormalizedRegion = (region: string, defaultCloudshellRegion: string) => {
if (!region) return defaultCloudshellRegion;
const regionMap: Record<string, string> = {
"centralus": "westcentralus",
"eastus2": "eastus"
};
const normalizedRegion = regionMap[region.toLowerCase()] || region;
return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion;
};
export async function getNetworkProfileInfo<T>(networkProfileResourceId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
return await GetARMCall<T>(networkProfileResourceId, apiVersion);
}
export async function getAccountDetails<T>(databaseAccountId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
return await GetARMCall<T>(databaseAccountId, apiVersion);
}
export async function getVnetInformation<T>(vnetId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
return await GetARMCall<T>(vnetId, apiVersion);
}
export async function getSubnetInformation<T>(subnetId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET);
return await GetARMCall<T>(subnetId, apiVersion);
}
export async function updateSubnetInformation<T>(subnetId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET);
return await PutARMCall(subnetId, request, apiVersion);
}
export async function updateDatabaseAccount<T>(accountId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
return await PutARMCall(accountId, request, apiVersion);
}
export async function getDatabaseOperations<T>(accountId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
return await GetARMCall<T>(`${accountId}/operations`, apiVersion);
}
export async function updateVnet<T>(vnetId: string, request: object, apiVersionOverride?: string) {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
return await PutARMCall<T>(vnetId, request, apiVersion);
}
export async function getVnet<T>(vnetId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
return await GetARMCall<T>(vnetId, apiVersion);
}
export async function createNetworkProfile<T>(networkProfileId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
return await PutARMCall<T>(networkProfileId, request, apiVersion);
}
export async function createRelay<T>(relayId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY);
return await PutARMCall<T>(relayId, request, apiVersion);
}
export async function getRelay<T>(relayId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY);
return await GetARMCall<T>(relayId, apiVersion);
}
export async function createRoleOnNetworkProfile<T>(roleid: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE);
return await PutARMCall<T>(roleid, request, apiVersion);
}
export async function createRoleOnRelay<T>(roleid: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE);
return await PutARMCall<T>(roleid, request, apiVersion);
}
export async function GetARMCall<T>(path: string, apiVersion: string = API_VERSIONS.DEFAULT): Promise<T> {
return await armRequest<T>({
host: configContext.ARM_ENDPOINT,
path: path,
method: "GET",
apiVersion: apiVersion
});
}
export async function PutARMCall<T>(path: string, request: object, apiVersion: string = API_VERSIONS.DEFAULT): Promise<T> {
return await armRequest<T>({
host: configContext.ARM_ENDPOINT,
path: path,
method: "PUT",
apiVersion: apiVersion,
body: request
});
}

View File

@@ -0,0 +1,263 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* CloudShell API client for various operations
*/
import { v4 as uuidv4 } from 'uuid';
import { configContext } from "../../../../ConfigContext";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from '../../../../UserContext';
import { armRequest } from "../../../../Utils/arm/request";
import { ApiVersionsConfig, DEFAULT_API_VERSIONS } from "../Models/ApiVersions";
import { Authorization, ConnectTerminalResponse, NetworkType, OsType, ProvisionConsoleResponse, ResourceType, SessionType, Settings, ShellType } from "../Models/DataModels";
import { getLocale } from '../Data/LocalizationUtils';
// Current shell type context
let currentShellType: TerminalKind | null = null;
/**
* Set the active shell type to determine API version
*/
export const setShellType = (shellType: TerminalKind): void => {
currentShellType = shellType;
};
/**
* Get the appropriate API version based on shell type and resource type
*/
export const getApiVersion = (resourceType?: ResourceType, apiVersions?: ApiVersionsConfig): string => {
if (!apiVersions) {
apiVersions = DEFAULT_API_VERSIONS; // Default fallback
}
// Shell type is set, try to get specific version in this priority:
// 1. Shell-specific + resource-specific
if (resourceType &&
apiVersions.SHELL_TYPES[currentShellType]) {
const shellTypeConfig = apiVersions.SHELL_TYPES[currentShellType];
if (resourceType in shellTypeConfig) {
return shellTypeConfig[resourceType] as string;
}
}
// 2. Resource-specific default
if (resourceType && resourceType in apiVersions.RESOURCE_DEFAULTS) {
return apiVersions.RESOURCE_DEFAULTS[resourceType];
}
// 3. Global default
return apiVersions.DEFAULT;
};
export const getUserRegion = async (subscriptionId: string, resourceGroup: string, accountName: string) => {
return await armRequest({
host: configContext.ARM_ENDPOINT,
path: `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`,
method: "GET",
apiVersion: "2022-12-01"
});
};
export const deleteUserSettings = async (): Promise<void> => {
await armRequest<void>({
host: configContext.ARM_ENDPOINT,
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
method: "DELETE",
apiVersion: "2023-02-01-preview"
});
};
export const getUserSettings = async (): Promise<Settings> => {
const resp = await armRequest<any>({
host: configContext.ARM_ENDPOINT,
path: `/providers/Microsoft.Portal/userSettings/cloudconsole`,
method: "GET",
apiVersion: "2023-02-01-preview"
});
return resp;
};
export const putEphemeralUserSettings = async (userSubscriptionId: string, userRegion: string, vNetSettings?: object) => {
const ephemeralSettings = {
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) => {
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 (subscriptionId: string, location: string): Promise<ProvisionConsoleResponse> => {
const data = {
properties: {
osType: OsType.Linux
}
};
return await armRequest<any>({
host: configContext.ARM_ENDPOINT,
path: `providers/Microsoft.Portal/consoles/default`,
method: "PUT",
apiVersion: "2023-02-01-preview",
customHeaders: {
'x-ms-console-preferred-location': location
},
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
});
return resp.json();
};
export const authorizeSession = async (consoleUri: string): Promise<Authorization> => {
const targetUri = consoleUri + "/authorize";
const resp = await fetch(targetUri, {
method: "POST",
headers: {
'Accept': 'application/json',
'Authorization': userContext.authorizationToken,
'Accept-Language': getLocale(),
"Content-Type": 'application/json'
},
body: "{}" // empty body is necessary
});
return resp.json();
};
export async function getNetworkProfileInfo<T>(networkProfileResourceId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
return await GetARMCall<T>(networkProfileResourceId, apiVersion);
}
export async function getAccountDetails<T>(databaseAccountId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
return await GetARMCall<T>(databaseAccountId, apiVersion);
}
export async function getVnetInformation<T>(vnetId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
return await GetARMCall<T>(vnetId, apiVersion);
}
export async function getSubnetInformation<T>(subnetId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET);
return await GetARMCall<T>(subnetId, apiVersion);
}
export async function updateSubnetInformation<T>(subnetId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.SUBNET);
return await PutARMCall<T>(subnetId, request, apiVersion);
}
export async function updateDatabaseAccount<T>(accountId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
return await PutARMCall<T>(accountId, request, apiVersion);
}
export async function getDatabaseOperations<T>(accountId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.DATABASE);
return await GetARMCall<T>(`${accountId}/operations`, apiVersion);
}
export async function updateVnet<T>(vnetId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
return await PutARMCall<T>(vnetId, request, apiVersion);
}
export async function getVnet<T>(vnetId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.VNET);
return await GetARMCall<T>(vnetId, apiVersion);
}
export async function createNetworkProfile<T>(networkProfileId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
return await PutARMCall<T>(networkProfileId, request, apiVersion);
}
export async function createRelay<T>(relayId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY);
return await PutARMCall<T>(relayId, request, apiVersion);
}
export async function getRelay<T>(relayId: string, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.RELAY);
return await GetARMCall<T>(relayId, apiVersion);
}
export async function createRoleOnNetworkProfile<T>(roleId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE);
return await PutARMCall<T>(roleId, request, apiVersion);
}
export async function createRoleOnRelay<T>(roleId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.ROLE);
return await PutARMCall<T>(roleId, request, apiVersion);
}
export async function createPrivateEndpoint<T>(privateEndpointId: string, request: object, apiVersionOverride?: string): Promise<T> {
const apiVersion = apiVersionOverride || getApiVersion(ResourceType.NETWORK);
return await PutARMCall<T>(privateEndpointId, request, apiVersion);
}
export async function GetARMCall<T>(path: string, apiVersion: string = '2024-07-01'): Promise<T> {
return await armRequest<T>({
host: configContext.ARM_ENDPOINT,
path: path,
method: "GET",
apiVersion: apiVersion
});
}
export async function PutARMCall<T>(path: string, request: object, apiVersion: string = '2024-07-01'): Promise<T> {
return await armRequest<T>({
host: configContext.ARM_ENDPOINT,
path: path,
method: "PUT",
apiVersion: apiVersion,
body: request
});
}

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Localization utilities for CloudShell
*/
/**
* Gets the current locale for API requests
*/
export const getLocale = (): string => {
const langLocale = navigator.language;
return (langLocale && langLocale.length > 2 ? langLocale : 'en-us');
};

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Region utilities for CloudShell
*/
export const getLocale = () => {
const langLocale = navigator.language;
return (langLocale && langLocale.length === 2 ? langLocale[1] : 'en-us');
};
const validCloudShellRegions = new Set([
"westus",
"southcentralus",
"eastus",
"northeurope",
"westeurope",
"centralindia",
"southeastasia",
"westcentralus"
]);
/**
* Normalizes a region name to a valid CloudShell region
* @param region The region to normalize
* @param defaultCloudshellRegion Default region to use if the provided region is not supported
*/
export const getNormalizedRegion = (region: string, defaultCloudshellRegion: string) => {
if (!region) return defaultCloudshellRegion;
const regionMap: Record<string, string> = {
"centralus": "westcentralus",
"eastus2": "eastus"
};
const normalizedRegion = regionMap[region.toLowerCase()] || region;
return validCloudShellRegions.has(normalizedRegion.toLowerCase()) ? normalizedRegion : defaultCloudshellRegion;
};

View File

@@ -0,0 +1,185 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*/
import { TerminalKind } from "../../../Contracts/ViewModels";
export const enum OsType {
Linux = "linux",
Windows = "windows"
}
export const enum ShellType {
Bash = "bash",
PowerShellCore = "pwsh"
}
export const enum NetworkType {
Default = "Default",
Isolated = "Isolated"
}
export const enum SessionType {
Mounted = "Mounted",
Ephemeral = "Ephemeral"
}
export const enum UserInputs {
NoReset = "1",
ConfigureVNet = "2",
ResetVNet = "3"
};
export type Settings = {
properties: UserSettingProperties
};
export type UserSettingProperties = {
networkType: string;
preferredLocation: string;
preferredOsType: OsType;
preferredShellType: ShellType;
userSubscription: string;
sessionType: SessionType;
vnetSettings: VnetSettings;
}
export type VnetSettings = {
networkProfileResourceId?: string;
relayNamespaceResourceId?: string;
location?: string;
}
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 VnetModel = {
name: string;
id: string;
etag: string;
type: string;
location: string;
tags: Record<string, string>;
properties: {
provisioningState: string;
resourceGuid: string;
addressSpace: {
addressPrefixes: string[];
};
encryption: {
enabled: boolean;
enforcement: string;
};
privateEndpointVNetPolicies: string;
subnets: Array<{
name: string;
id: string;
etag: string;
type: string;
properties: {
provisioningState: string;
addressPrefixes?: string[];
addressPrefix?: string;
networkSecurityGroup?: { id: string };
ipConfigurations?: { id: string }[];
ipConfigurationProfiles?: { id: string }[];
privateEndpoints?: { id: string }[];
serviceEndpoints?: Array<{
provisioningState: string;
service: string;
locations: string[];
}>;
delegations?: Array<{
name: string;
id: string;
etag: string;
type: string;
properties: {
provisioningState: string;
serviceName: string;
actions: string[];
};
}>;
purpose?: string;
privateEndpointNetworkPolicies?: string;
privateLinkServiceNetworkPolicies?: string;
};
}>;
virtualNetworkPeerings: any[];
enableDdosProtection: boolean;
};
};
export type RelayNamespace = {
id: string;
name: string;
type: string;
location: string;
tags: Record<string, string>;
properties: {
metricId: string;
serviceBusEndpoint: string;
provisioningState: string;
status: string;
createdAt: string;
updatedAt: string;
};
sku: {
name: string;
tier: string;
};
};
export type RelayNamespaceResponse = {
value: RelayNamespace[];
};
/**
* Resource types for API versioning
*/
export enum ResourceType {
NETWORK = "NETWORK",
DATABASE = "DATABASE",
VNET = "VNET",
SUBNET = "SUBNET",
RELAY = "RELAY",
ROLE = "ROLE"
}
// Type definition for API_VERSIONS configuration
export type ApiVersionsConfig = {
// Global default API version
DEFAULT: string;
// Resource-specific default API versions
RESOURCE_DEFAULTS: {
[key in ResourceType]: string;
};
// Shell-type specific configurations
SHELL_TYPES: {
[key in TerminalKind]?: {
// Resource-specific overrides for this shell type
[key in ResourceType]?: string;
};
};
};

View File

@@ -0,0 +1,29 @@
/**
* Standardized terminal logging functions for consistent formatting
*/
export const terminalLog = {
// Section headers
header: (message: string) => `\n\x1B[1;34m┌─ ${message} ${"─".repeat(Math.max(45 - message.length, 0))}\x1B[0m`,
subheader: (message: string) => `\x1B[1;36m├ ${message}\x1B[0m`,
sectionEnd: () => `\x1B[1;34m└${"─".repeat(50)}\x1B[0m\n`,
// Status messages
success: (message: string) => `\x1B[32m✓ ${message}\x1B[0m`,
warning: (message: string) => `\x1B[33m⚠ ${message}\x1B[0m`,
error: (message: string) => `\x1B[31m✗ ${message}\x1B[0m`,
info: (message: string) => `\x1B[34m${message}\x1B[0m`,
// Resource information
database: (message: string) => `\x1B[35m🔶 Database: ${message}\x1B[0m`,
vnet: (message: string) => `\x1B[36m🔷 Network: ${message}\x1B[0m`,
cloudshell: (message: string) => `\x1B[32m🔷 CloudShell: ${message}\x1B[0m`,
// Data formatting
item: (label: string, value: string) => `${label}: \x1B[32m${value}\x1B[0m`,
progress: (operation: string, status: string) => `\x1B[34m${operation}: \x1B[36m${status}\x1B[0m`,
// User interaction
prompt: (message: string) => `\x1B[1;37m${message}\x1B[0m`,
separator: () => `\x1B[30;1m${"─".repeat(50)}\x1B[0m`
};

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* API versions configuration for CloudShell
*/
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { ResourceType } from "./DataModels";
/**
* Configuration for API versions used by the CloudShell
*/
export type ApiVersionsConfig = {
DEFAULT: string;
RESOURCE_DEFAULTS: Record<ResourceType, string>;
SHELL_TYPES: Record<TerminalKind, Record<ResourceType, string>>;
}
/**
* Default API versions configuration
*/
export const DEFAULT_API_VERSIONS: ApiVersionsConfig = {
DEFAULT: '2024-07-01',
RESOURCE_DEFAULTS: {
[ResourceType.DATABASE]: '2024-11-15',
[ResourceType.NETWORK]: '2024-07-01',
[ResourceType.VNET]: '2024-07-01',
[ResourceType.SUBNET]: '2024-07-01',
[ResourceType.RELAY]: '2022-10-01',
[ResourceType.ROLE]: '2022-04-01',
},
SHELL_TYPES: {
[TerminalKind.Mongo]: {
[ResourceType.DATABASE]: '2024-11-15',
[ResourceType.NETWORK]: '2024-07-01',
[ResourceType.VNET]: '2024-07-01',
[ResourceType.SUBNET]: '2024-07-01',
[ResourceType.RELAY]: '2024-01-01',
[ResourceType.ROLE]: '2022-04-01',
},
[TerminalKind.VCoreMongo]: {
[ResourceType.DATABASE]: '2024-07-01',
[ResourceType.NETWORK]: '2024-07-01',
[ResourceType.VNET]: '2024-07-01',
[ResourceType.SUBNET]: '2024-07-01',
[ResourceType.RELAY]: '2024-01-01',
[ResourceType.ROLE]: '2022-04-01',
},
[TerminalKind.Postgres]: {
[ResourceType.DATABASE]: '2024-11-15',
[ResourceType.NETWORK]: '2024-07-01',
[ResourceType.VNET]: '2024-07-01',
[ResourceType.SUBNET]: '2024-07-01',
[ResourceType.RELAY]: '2024-01-01',
[ResourceType.ROLE]: '2022-04-01',
},
[TerminalKind.Cassandra]: {
[ResourceType.DATABASE]: '2024-11-15',
[ResourceType.NETWORK]: '2024-07-01',
[ResourceType.VNET]: '2024-07-01',
[ResourceType.SUBNET]: '2024-07-01',
[ResourceType.RELAY]: '2024-01-01',
[ResourceType.ROLE]: '2022-04-01',
},
[TerminalKind.Default]: {
[ResourceType.DATABASE]: undefined,
[ResourceType.NETWORK]: undefined,
[ResourceType.VNET]: undefined,
[ResourceType.SUBNET]: undefined,
[ResourceType.RELAY]: undefined,
[ResourceType.ROLE]: undefined,
},
},
};

View File

@@ -0,0 +1,163 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Data models for CloudShell
*/
export const enum OsType {
Linux = "linux",
Windows = "windows"
}
export const enum ShellType {
Bash = "bash",
PowerShellCore = "pwsh"
}
export const enum NetworkType {
Default = "Default",
Isolated = "Isolated"
}
export const enum SessionType {
Mounted = "Mounted",
Ephemeral = "Ephemeral"
}
export const enum UserInputs {
NoReset = "1",
ConfigureVNet = "2",
ResetVNet = "3"
};
export type Settings = {
properties: UserSettingProperties
};
export type UserSettingProperties = {
networkType: string;
preferredLocation: string;
preferredOsType: OsType;
preferredShellType: ShellType;
userSubscription: string;
sessionType: SessionType;
vnetSettings: VnetSettings;
}
export type VnetSettings = {
networkProfileResourceId?: string;
relayNamespaceResourceId?: string;
location?: string;
}
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 VnetModel = {
name: string;
id: string;
etag: string;
type: string;
location: string;
tags: Record<string, string>;
properties: {
provisioningState: string;
resourceGuid: string;
addressSpace: {
addressPrefixes: string[];
};
encryption: {
enabled: boolean;
enforcement: string;
};
privateEndpointVNetPolicies: string;
subnets: Array<{
name: string;
id: string;
etag: string;
type: string;
properties: {
provisioningState: string;
addressPrefixes?: string[];
addressPrefix?: string;
networkSecurityGroup?: { id: string };
ipConfigurations?: { id: string }[];
ipConfigurationProfiles?: { id: string }[];
privateEndpoints?: { id: string }[];
serviceEndpoints?: Array<{
provisioningState: string;
service: string;
locations: string[];
}>;
delegations?: Array<{
name: string;
id: string;
etag: string;
type: string;
properties: {
provisioningState: string;
serviceName: string;
actions: string[];
};
}>;
purpose?: string;
privateEndpointNetworkPolicies?: string;
privateLinkServiceNetworkPolicies?: string;
};
}>;
virtualNetworkPeerings: any[];
enableDdosProtection: boolean;
};
};
export type RelayNamespace = {
id: string;
name: string;
type: string;
location: string;
tags: Record<string, string>;
properties: {
metricId: string;
serviceBusEndpoint: string;
provisioningState: string;
status: string;
createdAt: string;
updatedAt: string;
};
sku: {
name: string;
tier: string;
};
};
export type RelayNamespaceResponse = {
value: RelayNamespace[];
};
/**
* Resource types for API versioning
*/
export enum ResourceType {
NETWORK = "NETWORK",
DATABASE = "DATABASE",
VNET = "VNET",
SUBNET = "SUBNET",
RELAY = "RELAY",
ROLE = "ROLE"
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Firewall handling functionality for CloudShell
*/
import { Terminal } from "xterm";
import { userContext } from "../../../../UserContext";
import { hasFirewallRestrictions } from "../../Shared/CheckFirewallRules";
import { getAccountDetails, updateDatabaseAccount } from "../Data/CloudShellApiClient";
import { askConfirmation } from "../Utils/CommonUtils";
import { terminalLog } from "../Utils/LogFormatter";
export class FirewallHandler {
/**
* Checks if firewall configuration is needed for CloudShell
*/
public static async checkFirewallConfiguration(terminal: Terminal): Promise<boolean> {
if (!hasFirewallRestrictions()) {
return false; // No firewall rules to configure
}
terminal.writeln(terminalLog.header("Database Firewall Configuration"));
terminal.writeln(terminalLog.warning("Your database has firewall restrictions enabled"));
terminal.writeln(terminalLog.warning("CloudShell might need access through these restrictions"));
const shouldConfigureFirewall = await askConfirmation(
terminal,
"Would you like to check and configure firewall settings?"
);
if (!shouldConfigureFirewall) {
terminal.writeln(terminalLog.info("Skipping firewall configuration"));
return false;
}
return await this.configureFirewallForCloudShell(terminal);
}
/**
* Configures firewall for CloudShell access
*/
private static async configureFirewallForCloudShell(terminal: Terminal): Promise<boolean> {
try {
// Get current database account details
terminal.writeln(terminalLog.database("Retrieving current firewall configuration..."));
const dbAccount = userContext.databaseAccount;
const currentDbAccount = await getAccountDetails(dbAccount.id);
// Check if "Allow Azure Services" is already enabled
const ipRules = currentDbAccount.properties.ipRules || [];
const azureServicesEnabled = currentDbAccount.properties.publicNetworkAccess === "Enabled";
if (azureServicesEnabled) {
terminal.writeln(terminalLog.success("Azure services access is already enabled"));
return true;
}
// Ask user to enable Azure services access
terminal.writeln(terminalLog.warning("Azure services access is not enabled"));
terminal.writeln(terminalLog.info("CloudShell requires 'Allow Azure Services' to be enabled"));
const enableAzureServices = await askConfirmation(
terminal,
"Enable 'Allow Azure Services' for this database?"
);
if (!enableAzureServices) {
terminal.writeln(terminalLog.warning("CloudShell may not be able to connect without enabling Azure services access"));
return false;
}
// Update database account to enable Azure services access
terminal.writeln(terminalLog.info("Updating database firewall configuration..."));
// Create update payload - only modify firewall-related properties
const updatePayload = {
...currentDbAccount,
properties: {
...currentDbAccount.properties,
publicNetworkAccess: "Enabled"
}
};
await updateDatabaseAccount(dbAccount.id, updatePayload);
terminal.writeln(terminalLog.success("Database firewall updated successfully"));
terminal.writeln(terminalLog.success("Azure services access is now enabled"));
return true;
} catch (error) {
terminal.writeln(terminalLog.error(`Error configuring firewall: ${error.message}`));
return false;
}
}
}

View File

@@ -0,0 +1,99 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Network access configuration handler for CloudShell
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { IsPublicAccessAvailable } from "../../Shared/CheckFirewallRules";
import { getUserSettings } from "../Data/CloudShellApiClient";
import { VnetSettings } from "../Models/DataModels";
import { terminalLog } from "../Utils/LogFormatter";
import { VNetHandler } from "./VNetHandler";
export class NetworkAccessHandler {
/**
* Configures network access for the CloudShell based on shell type and network restrictions
*/
public static async configureNetworkAccess(
terminal: Terminal,
region: string,
shellType: TerminalKind
): Promise<{
vNetSettings: any;
isAllPublicAccessEnabled: boolean;
}> {
// Check if public access is available for this shell type
const isAllPublicAccessEnabled = await IsPublicAccessAvailable(shellType);
// If public access is enabled, no need for VNet configuration
if (isAllPublicAccessEnabled) {
terminal.writeln(terminalLog.database("Public access enabled. Skipping VNet configuration."));
return {
vNetSettings: {},
isAllPublicAccessEnabled: true
};
}
// Public access is restricted, we need to configure a VNet or use existing one
terminal.writeln(terminalLog.database("Network restrictions detected"));
terminal.writeln(terminalLog.info("Loading CloudShell configuration..."));
// Get existing settings if available
const settings = await getUserSettings();
if (!settings) {
terminal.writeln(terminalLog.warning("No existing user settings found."));
}
// Retrieve CloudShell VNet settings if available
let cloudShellVnetSettings: VnetSettings | undefined;
if (settings) {
cloudShellVnetSettings = await VNetHandler.retrieveCloudShellVnetSettings(settings, terminal);
}
// If CloudShell has VNet settings, check with database config
let finalVNetSettings = {};
if (cloudShellVnetSettings && cloudShellVnetSettings.networkProfileResourceId) {
// Check if we should use existing VNet settings
const isContinueWithSameVnet = await VNetHandler.askForVNetConfigConsent(terminal, shellType);
if (isContinueWithSameVnet) {
// Check if the VNet is already configured in the database
const isVNetInDatabaseConfig = await VNetHandler.isCloudShellVNetInDatabaseConfig(cloudShellVnetSettings, terminal);
if (!isVNetInDatabaseConfig) {
terminal.writeln(terminalLog.warning("CloudShell VNet is not configured in database access list"));
const addToDatabase = await VNetHandler.askToAddVNetToDatabase(terminal, cloudShellVnetSettings);
if (addToDatabase) {
await VNetHandler.addCloudShellVNetToDatabase(cloudShellVnetSettings, terminal);
finalVNetSettings = cloudShellVnetSettings;
} else {
// User declined to add VNet to database, need to recreate
terminal.writeln(terminalLog.warning("Will configure new VNet..."));
cloudShellVnetSettings = undefined;
}
} else {
terminal.writeln(terminalLog.success("CloudShell VNet is already in database configuration"));
finalVNetSettings = cloudShellVnetSettings;
}
} else {
cloudShellVnetSettings = undefined; // User declined to use existing VNet settings
}
}
// If we don't have valid VNet settings, create new ones
if (!cloudShellVnetSettings || !cloudShellVnetSettings.networkProfileResourceId) {
terminal.writeln(terminalLog.subheader("Configuring network infrastructure"));
finalVNetSettings = await VNetHandler.configureCloudShellVNet(terminal, region);
// Add the new VNet to the database
await VNetHandler.addCloudShellVNetToDatabase(finalVNetSettings as VnetSettings, terminal);
}
return {
vNetSettings: finalVNetSettings,
isAllPublicAccessEnabled: false
};
}
}

View File

@@ -0,0 +1,894 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* VNet handling functionality for CloudShell
*/
import { v4 as uuidv4 } from 'uuid';
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { hasPrivateEndpointsRestrictions } from "../../Shared/CheckFirewallRules";
import {
createNetworkProfile,
createPrivateEndpoint,
createRelay,
createRoleOnNetworkProfile,
createRoleOnRelay,
getAccountDetails,
getDatabaseOperations,
getNetworkProfileInfo,
getRelay,
getSubnetInformation,
getVnet,
getVnetInformation,
updateDatabaseAccount,
updateSubnetInformation,
updateVnet
} from "../Data/CloudShellApiClient";
import { Settings, VnetSettings } from "../Models/DataModels";
import { askConfirmation, askQuestion, wait } from "../Utils/CommonUtils";
import { terminalLog } from "../Utils/LogFormatter";
// Constants for VNet configuration
const POLLING_INTERVAL_MS = 5000;
const MAX_RETRY_COUNT = 10;
const STANDARD_SKU = "Standard";
const DEFAULT_VNET_ADDRESS_PREFIX = "10.0.0.0/16";
const DEFAULT_SUBNET_ADDRESS_PREFIX = "10.0.1.0/24";
const DEFAULT_CONTAINER_INSTANCE_OID = "88536fb9-d60a-4aee-8195-041425d6e927";
export class VNetHandler {
/**
* Retrieves CloudShell VNet settings from user settings
*/
public static async retrieveCloudShellVnetSettings(settings: Settings, terminal: Terminal): Promise<VnetSettings> {
if (settings?.properties?.vnetSettings && Object.keys(settings.properties.vnetSettings).length > 0) {
try {
const netProfileInfo = await getNetworkProfileInfo<any>(settings.properties.vnetSettings.networkProfileResourceId);
terminal.writeln(terminalLog.header("Existing Network Configuration"));
const subnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0]
.properties.ipConfigurations[0].properties.subnet.id;
const vnetResourceId = subnetId.replace(/\/subnets\/[^/]+$/, '');
terminal.writeln(terminalLog.item("VNet", vnetResourceId));
terminal.writeln(terminalLog.item("Subnet", subnetId));
terminal.writeln(terminalLog.item("Location", settings.properties.vnetSettings.location));
terminal.writeln(terminalLog.item("Network Profile", settings.properties.vnetSettings.networkProfileResourceId));
terminal.writeln(terminalLog.item("Relay Namespace", settings.properties.vnetSettings.relayNamespaceResourceId));
return {
networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId,
relayNamespaceResourceId: settings.properties.vnetSettings.relayNamespaceResourceId,
location: settings.properties.vnetSettings.location
};
} catch (err) {
terminal.writeln(terminalLog.warning("Error retrieving network profile. Will configure new network."));
return undefined;
}
}
return undefined;
}
/**
* Asks the user if they want to use existing network configuration (VNet or private endpoint)
*/
public static async askForVNetConfigConsent(terminal: Terminal, shellType: TerminalKind = null): Promise<boolean> {
// Check if this shell type supports only private endpoints
const isPrivateEndpointOnlyShell = shellType === TerminalKind.VCoreMongo;
// Check if the database has private endpoints configured
const hasPrivateEndpoints = hasPrivateEndpointsRestrictions();
// Determine which network type to mention based on shell type and database configuration
const networkType = isPrivateEndpointOnlyShell || hasPrivateEndpoints ? "private endpoint" : "network";
// Ask for consent
terminal.writeln("");
terminal.writeln(terminalLog.prompt(`Use this existing ${networkType} configuration?`));
terminal.writeln(terminalLog.info(`Answering 'N' will configure a new ${networkType} for CloudShell`));
return await askConfirmation(terminal, `Press Y/N to continue...`);
}
/**
* Checks if the CloudShell VNet is already in the database configuration
*/
public static async isCloudShellVNetInDatabaseConfig(vNetSettings: VnetSettings, terminal: Terminal): Promise<boolean> {
try {
terminal.writeln(terminalLog.subheader("Verifying if CloudShell VNet is configured in database"));
// Get the subnet ID from the CloudShell Network Profile
const netProfileInfo = await getNetworkProfileInfo<any>(vNetSettings.networkProfileResourceId);
if (!netProfileInfo?.properties?.containerNetworkInterfaceConfigurations?.[0]
?.properties?.ipConfigurations?.[0]?.properties?.subnet?.id) {
terminal.writeln(terminalLog.warning("Could not retrieve subnet ID from CloudShell VNet"));
return false;
}
const cloudShellSubnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0]
.properties.ipConfigurations[0].properties.subnet.id;
terminal.writeln(terminalLog.item("CloudShell Subnet", cloudShellSubnetId.split('/').pop() || ""));
// Check if this subnet ID is in the database VNet rules
const dbAccount = userContext.databaseAccount;
if (!dbAccount?.properties?.virtualNetworkRules) {
return false;
}
const vnetRules = dbAccount.properties.virtualNetworkRules;
// Check if the CloudShell subnet is already in the rules
return vnetRules.some(rule => rule.id === cloudShellSubnetId);
} catch (err) {
terminal.writeln(terminalLog.error("Error checking database VNet configuration"));
return false;
}
}
/**
* Asks the user if they want to add the CloudShell VNet to the database configuration
*/
public static async askToAddVNetToDatabase(terminal: Terminal, vNetSettings: VnetSettings): Promise<boolean> {
terminal.writeln("");
terminal.writeln(terminalLog.header("Network Configuration Mismatch"));
terminal.writeln(terminalLog.warning("Your CloudShell VNet is not in your database's allowed networks"));
terminal.writeln(terminalLog.warning("To connect from CloudShell, this VNet must be added to your database"));
return await askConfirmation(terminal, "Add CloudShell VNet to database configuration?");
}
/**
* Adds the CloudShell VNet to the database configuration
* Now supports both VNet rules and private endpoints
*/
public static async addCloudShellVNetToDatabase(vNetSettings: VnetSettings, terminal: Terminal): Promise<void> {
try {
terminal.writeln(terminalLog.header("Updating database network configuration"));
// Step 1: Get the subnet ID from CloudShell Network Profile
const { cloudShellSubnetId, cloudShellVnetId } = await this.getCloudShellNetworkIds(vNetSettings, terminal);
// Step 2: Get current database account details
const { currentDbAccount } = await this.getDatabaseAccountDetails(terminal);
// Step 3: Determine if database uses private endpoints
const usesPrivateEndpoints = hasPrivateEndpointsRestrictions() ||
(currentDbAccount.properties.privateEndpointConnections?.length > 0);
// Log which networking mode we're using
if (usesPrivateEndpoints) {
terminal.writeln(terminalLog.info("Database is configured with private endpoints"));
} else {
terminal.writeln(terminalLog.info("Database is configured with VNet rules"));
}
// Step 4: Check if connection is already configured
if (usesPrivateEndpoints) {
if (await this.isPrivateEndpointAlreadyConfigured(cloudShellVnetId, currentDbAccount, terminal)) {
return;
}
} else {
if (await this.isVNetAlreadyConfigured(cloudShellSubnetId, currentDbAccount, terminal)) {
return;
}
}
// Step 5: Check network resource statuses and ongoing operations
const { vnetInfo, subnetInfo, operationInProgress } =
await this.checkNetworkResourceStatuses(cloudShellSubnetId, cloudShellVnetId, currentDbAccount.id, terminal);
// Step 6: If no operation in progress, update the configuration
if (!operationInProgress) {
if (usesPrivateEndpoints) {
// Create or update private endpoint configuration
await this.configurePrivateEndpoint(
cloudShellSubnetId,
vnetInfo.location,
currentDbAccount.id,
terminal
);
} else {
// Enable CosmosDB service endpoint on subnet if needed (for VNet rules)
await this.enableCosmosDBServiceEndpoint(cloudShellSubnetId, subnetInfo, terminal);
// Update database account with VNet rule
await this.updateDatabaseWithVNetRule(currentDbAccount, cloudShellSubnetId, currentDbAccount.id, terminal);
}
} else {
terminal.writeln(terminalLog.info("Monitoring existing network operation..."));
// Step 7: Monitor the update progress
await this.monitorVNetAdditionProgress(cloudShellSubnetId, currentDbAccount.id, terminal);
}
} catch (err) {
terminal.writeln(terminalLog.error(`Error updating database network configuration: ${err.message}`));
throw err;
}
}
/**
* Checks if a private endpoint is already configured for the CloudShell VNet
*/
private static async isPrivateEndpointAlreadyConfigured(
cloudShellVnetId: string,
currentDbAccount: any,
terminal: Terminal
): Promise<boolean> {
// Check if private endpoints exist and are properly configured for this VNet
const hasConfiguredEndpoint = currentDbAccount.properties.privateEndpointConnections?.some(
(connection: any) => {
const isApproved = connection.properties.privateLinkServiceConnectionState.status === 'Approved';
// We would need to check if the endpoint is in the CloudShell VNet
// For simplicity, we're assuming connection.properties.networkInterface contains this info
const endpointVNetId = connection.properties.networkInterface?.id?.split('/subnets/')[0];
return isApproved && endpointVNetId === cloudShellVnetId;
}
);
if (hasConfiguredEndpoint) {
terminal.writeln(terminalLog.success("CloudShell private endpoint is already configured"));
return true;
}
return false;
}
/**
* Configures a private endpoint for the CloudShell VNet to connect to the database
*/
private static async configurePrivateEndpoint(
cloudShellSubnetId: string,
vnetLocation: any,
dbAccountId: string,
terminal: Terminal
): Promise<void> {
// Extract necessary information from IDs
const subnetIdParts = cloudShellSubnetId.split('/');
const subnetIndex = subnetIdParts.indexOf('subnets');
const subnetName = subnetIdParts[subnetIndex + 1];
const resourceGroup = subnetIdParts[4];
const subscriptionId = subnetIdParts[2];
// Generate a unique name for the private endpoint
const privateEndpointName = `pe-cloudshell-cosmos-${Math.floor(10000 + Math.random() * 90000)}`;
terminal.writeln(terminalLog.subheader("Creating private endpoint for CloudShell"));
terminal.writeln(terminalLog.item("Private Endpoint Name", privateEndpointName));
terminal.writeln(terminalLog.item("Target Subnet", subnetName));
// Construct the private endpoint creation payload
const privateEndpointPayload = {
location: vnetLocation,
properties: {
privateLinkServiceConnections: [
{
name: privateEndpointName,
properties: {
privateLinkServiceId: dbAccountId,
groupIds: [
"MongoDB"
],
requestMessage: "CloudShell connectivity request"
},
type: "Microsoft.Network/privateEndpoints/privateLinkServiceConnections"
}
],
subnet: {
id: cloudShellSubnetId
}
}
};
// Send the request to create the private endpoint
// Note: This is a placeholder - we would need to implement this API call
terminal.writeln(terminalLog.info("Submitting private endpoint creation request"));
try {
const privateEndpointUrl = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/privateEndpoints/${privateEndpointName}`;
await createPrivateEndpoint(privateEndpointUrl, privateEndpointPayload, "2024-05-01");
terminal.writeln(terminalLog.success("Private endpoint creation request submitted"));
terminal.writeln(terminalLog.warning("Please approve the private endpoint connection in the Azure portal"));
terminal.writeln(terminalLog.info("Note: Private endpoint operations may take several minutes to complete"));
} catch (err) {
terminal.writeln(terminalLog.error(`Failed to create private endpoint: ${err.message}`));
throw err;
}
}
/**
* Gets the subnet and VNet IDs from CloudShell Network Profile
*/
private static async getCloudShellNetworkIds(vNetSettings: VnetSettings, terminal: Terminal): Promise<{ cloudShellSubnetId: string; cloudShellVnetId: string }> {
const netProfileInfo = await getNetworkProfileInfo<any>(vNetSettings.networkProfileResourceId);
if (!netProfileInfo?.properties?.containerNetworkInterfaceConfigurations?.[0]
?.properties?.ipConfigurations?.[0]?.properties?.subnet?.id) {
throw new Error("Could not retrieve subnet ID from CloudShell VNet");
}
const cloudShellSubnetId = netProfileInfo.properties.containerNetworkInterfaceConfigurations[0]
.properties.ipConfigurations[0].properties.subnet.id;
// Extract VNet ID from subnet ID
const cloudShellVnetId = cloudShellSubnetId.substring(0, cloudShellSubnetId.indexOf('/subnets/'));
terminal.writeln(terminalLog.subheader("Identified CloudShell network resources"));
terminal.writeln(terminalLog.item("Subnet", cloudShellSubnetId.split('/').pop() || ""));
terminal.writeln(terminalLog.item("VNet", cloudShellVnetId.split('/').pop() || ""));
return { cloudShellSubnetId, cloudShellVnetId };
}
/**
* Gets the database account details
*/
private static async getDatabaseAccountDetails(terminal: Terminal): Promise<{ currentDbAccount: any }> {
const dbAccount = userContext.databaseAccount;
terminal.writeln(terminalLog.database("Verifying current configuration"));
const currentDbAccount = await getAccountDetails(dbAccount.id);
return { currentDbAccount };
}
/**
* Checks if the VNet is already configured in the database
*/
private static async isVNetAlreadyConfigured(cloudShellSubnetId: string, currentDbAccount: any, terminal: Terminal): Promise<boolean> {
const vnetAlreadyConfigured = currentDbAccount.properties.virtualNetworkRules &&
currentDbAccount.properties.virtualNetworkRules.some(
(rule: any) => rule.id === cloudShellSubnetId
);
if (vnetAlreadyConfigured) {
terminal.writeln(terminalLog.success("CloudShell VNet is already in database configuration"));
return true;
}
return false;
}
/**
* Checks the status of network resources and ongoing operations
*/
private static async checkNetworkResourceStatuses(
cloudShellSubnetId: string,
cloudShellVnetId: string,
dbAccountId: string,
terminal: Terminal
): Promise<{ vnetInfo: any; subnetInfo: any; operationInProgress: boolean }> {
terminal.writeln(terminalLog.subheader("Checking network resource status"));
let operationInProgress = false;
let vnetInfo: any = null;
let subnetInfo: any = null;
if (cloudShellVnetId && cloudShellSubnetId) {
// Get VNet and subnet resource status
vnetInfo = await getVnetInformation<any>(cloudShellVnetId);
subnetInfo = await getSubnetInformation<any>(cloudShellSubnetId);
// Check if there's an ongoing operation on the VNet or subnet
const vnetProvisioningState = vnetInfo?.properties?.provisioningState;
const subnetProvisioningState = subnetInfo?.properties?.provisioningState;
if (vnetProvisioningState !== 'Succeeded' && vnetProvisioningState !== 'Failed') {
terminal.writeln(terminalLog.warning(`VNet operation in progress: ${vnetProvisioningState}`));
operationInProgress = true;
}
if (subnetProvisioningState !== 'Succeeded' && subnetProvisioningState !== 'Failed') {
terminal.writeln(terminalLog.warning(`Subnet operation in progress: ${subnetProvisioningState}`));
operationInProgress = true;
}
// Also check database operations
const latestDbAccount = await getAccountDetails<any>(dbAccountId);
if (latestDbAccount.properties.virtualNetworkRules) {
const isPendingAdd = latestDbAccount.properties.virtualNetworkRules.some(
(rule: any) => rule.id === cloudShellSubnetId && rule.status === 'Updating'
);
if (isPendingAdd) {
terminal.writeln(terminalLog.warning("CloudShell VNet addition to database is already in progress"));
operationInProgress = true;
}
}
}
return { vnetInfo, subnetInfo, operationInProgress };
}
/**
* Enables the CosmosDB service endpoint on a subnet if needed
*/
private static async enableCosmosDBServiceEndpoint(cloudShellSubnetId: string, subnetInfo: any, terminal: Terminal): Promise<void> {
if (!subnetInfo) {
terminal.writeln(terminalLog.warning("Unable to check subnet endpoint configuration"));
return;
}
terminal.writeln(terminalLog.subheader("Checking and configuring CosmosDB service endpoint"));
// Parse the subnet ID to get resource information
const subnetIdParts = cloudShellSubnetId.split('/');
const subnetIndex = subnetIdParts.indexOf('subnets');
if (subnetIndex > 0) {
const subnetName = subnetIdParts[subnetIndex + 1];
const vnetName = subnetIdParts[subnetIndex - 1];
const resourceGroup = subnetIdParts[4];
const subscriptionId = subnetIdParts[2];
// Get the subnet URL
const subnetUrl = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}/subnets/${subnetName}`;
// Check if CosmosDB service endpoint is already enabled
const hasCosmosDBEndpoint = subnetInfo.properties.serviceEndpoints &&
subnetInfo.properties.serviceEndpoints.some(
(endpoint: any) => endpoint.service === 'Microsoft.AzureCosmosDB'
);
if (!hasCosmosDBEndpoint) {
terminal.writeln(terminalLog.warning("Enabling CosmosDB service endpoint on subnet..."));
// Create update payload with CosmosDB service endpoint
const serviceEndpoints = [
...(subnetInfo.properties.serviceEndpoints || []),
{ service: 'Microsoft.AzureCosmosDB' }
];
// Update the subnet configuration while preserving existing properties
const subnetUpdatePayload = {
...subnetInfo,
properties: {
...subnetInfo.properties,
serviceEndpoints: serviceEndpoints
}
};
// Apply the subnet update
await updateSubnetInformation(subnetUrl, subnetUpdatePayload);
// Wait for the subnet update to complete
let subnetUpdateComplete = false;
let subnetRetryCount = 0;
while (!subnetUpdateComplete && subnetRetryCount < MAX_RETRY_COUNT) {
const updatedSubnet = await getSubnetInformation<any>(subnetUrl);
const endpointEnabled = updatedSubnet.properties.serviceEndpoints &&
updatedSubnet.properties.serviceEndpoints.some(
(endpoint: any) => endpoint.service === 'Microsoft.AzureCosmosDB'
);
if (endpointEnabled && updatedSubnet.properties.provisioningState === 'Succeeded') {
subnetUpdateComplete = true;
terminal.writeln(terminalLog.success("CosmosDB service endpoint enabled successfully"));
} else {
subnetRetryCount++;
terminal.writeln(terminalLog.progress("Subnet update", `Waiting (${subnetRetryCount}/${MAX_RETRY_COUNT})`));
await wait(POLLING_INTERVAL_MS);
}
}
if (!subnetUpdateComplete) {
throw new Error("Failed to enable CosmosDB service endpoint on subnet");
}
} else {
terminal.writeln(terminalLog.success("CosmosDB service endpoint is already enabled"));
}
}
}
/**
* Updates the database account with a new VNet rule
*/
private static async updateDatabaseWithVNetRule(currentDbAccount: any, cloudShellSubnetId: string, dbAccountId: string, terminal: Terminal): Promise<void> {
// Create a deep copy of the current database account
const updatePayload = JSON.parse(JSON.stringify(currentDbAccount));
// Update only the network-related properties
updatePayload.properties.virtualNetworkRules = [
...(currentDbAccount.properties.virtualNetworkRules || []),
{ id: cloudShellSubnetId, ignoreMissingVNetServiceEndpoint: false }
];
updatePayload.properties.isVirtualNetworkFilterEnabled = true;
// Update the database account
terminal.writeln(terminalLog.subheader("Submitting VNet update request to database"));
await updateDatabaseAccount(dbAccountId, updatePayload);
terminal.writeln(terminalLog.success("Updated Database account with Cloud Shell Vnet"));
}
/**
* Monitors the progress of adding a VNet to the database account
*/
private static async monitorVNetAdditionProgress(cloudShellSubnetId: string, dbAccountId: string, terminal: Terminal): Promise<void> {
let updateComplete = false;
let retryCount = 0;
let lastStatus = "";
let lastProgress = 0;
let lastOpId = "";
terminal.writeln(terminalLog.subheader("Monitoring database update progress"));
while (!updateComplete && retryCount < MAX_RETRY_COUNT) {
// Check if the VNet is now in the database account
const updatedDbAccount = await getAccountDetails<any>(dbAccountId);
const isVNetAdded = updatedDbAccount.properties.virtualNetworkRules?.some(
(rule: any) => rule.id === cloudShellSubnetId && (!rule.status || rule.status === 'Succeeded')
);
if (isVNetAdded) {
updateComplete = true;
terminal.writeln(terminalLog.success("CloudShell VNet successfully added to database configuration"));
break;
}
// If not yet added, check for operation progress
const operations = await getDatabaseOperations<any>(dbAccountId);
// Find network-related operations
const networkOps = operations.value?.filter(
(op: any) =>
(op.properties.description?.toLowerCase().includes('network') ||
op.properties.description?.toLowerCase().includes('vnet'))
) || [];
// Find active operations
const activeOp = networkOps.find((op: any) => op.properties.status === 'InProgress');
if (activeOp) {
// Show progress details if available
const currentStatus = activeOp.properties.status;
const progress = activeOp.properties.percentComplete || 0;
const opId = activeOp.name;
// Only update the terminal if something has changed
if (currentStatus !== lastStatus || progress !== lastProgress || opId !== lastOpId) {
// Create a progress bar
const progressBarLength = 20;
const filledLength = Math.floor(progress / 100 * progressBarLength);
const progressBar = "█".repeat(filledLength) + "░".repeat(progressBarLength - filledLength);
terminal.writeln(`\x1B[34m [${progressBar}] ${progress}% - ${currentStatus}\x1B[0m`);
lastStatus = currentStatus;
lastProgress = progress;
lastOpId = opId;
}
} else if (networkOps.length > 0) {
// If there are completed operations, show their status
const lastCompletedOp = networkOps[0];
if (lastCompletedOp.properties.status !== lastStatus) {
terminal.writeln(terminalLog.progress("Operation status", lastCompletedOp.properties.status));
lastStatus = lastCompletedOp.properties.status;
}
}
retryCount++;
await wait(POLLING_INTERVAL_MS);
}
if (!updateComplete) {
terminal.writeln(terminalLog.warning("Database update timed out. Please check the Azure portal."));
}
}
/**
* Configures a new VNet for CloudShell
*/
public static async configureCloudShellVNet(terminal: Terminal, resolvedRegion: string): Promise<VnetSettings> {
// Use professional and shorter names for resources
const randomSuffix = Math.floor(10000 + Math.random() * 90000);
const subnetName = `cloudshell-subnet-${randomSuffix}`;
const vnetName = `cloudshell-vnet-${randomSuffix}`;
const networkProfileName = `cloudshell-network-profile-${randomSuffix}`;
const relayName = `cloudshell-relay-${randomSuffix}`;
terminal.writeln(terminalLog.header("Network Resource Configuration"));
const azureContainerInstanceOID = await askQuestion(
terminal,
"Enter Azure Container Instance OID (Refer. https://learn.microsoft.com/en-us/azure/cloud-shell/vnet/deployment#get-the-azure-container-instance-id)",
DEFAULT_CONTAINER_INSTANCE_OID
);
const vNetSubscriptionId = await askQuestion(
terminal,
"Enter Virtual Network Subscription ID",
userContext.subscriptionId
);
const vNetResourceGroup = await askQuestion(
terminal,
"Enter Virtual Network Resource Group",
userContext.resourceGroup
);
// Step 1: Create VNet with Subnet
terminal.writeln(terminalLog.header("Deploying Network Resources"));
const vNetConfigPayload = await this.createCloudShellVnet(
resolvedRegion,
subnetName,
terminal,
vnetName,
vNetSubscriptionId,
vNetResourceGroup
);
// Step 2: Create Network Profile
await this.createNetworkProfileWithVnet(
vNetSubscriptionId,
vNetResourceGroup,
vnetName,
subnetName,
resolvedRegion,
terminal,
networkProfileName
);
// Step 3: Create Network Relay
await this.createNetworkRelay(
resolvedRegion,
terminal,
relayName,
vNetSubscriptionId,
vNetResourceGroup
);
// Step 4: Assign Roles
terminal.writeln(terminalLog.header("Configuring Security Permissions"));
await this.assignRoleToNetworkProfile(
azureContainerInstanceOID,
vNetSubscriptionId,
terminal,
networkProfileName,
vNetResourceGroup
);
await this.assignRoleToRelay(
azureContainerInstanceOID,
vNetSubscriptionId,
terminal,
relayName,
vNetResourceGroup
);
// Step 5: Create and return VNet settings
const networkProfileResourceId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName.replace(/[\n\r]/g, "")}`;
const relayResourceId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName.replace(/[\n\r]/g, "")}`;
terminal.writeln(terminalLog.success("Network configuration complete"));
return {
networkProfileResourceId,
relayNamespaceResourceId: relayResourceId,
location: vNetConfigPayload.location
};
}
/**
* Creates a VNet for CloudShell
*/
private static async createCloudShellVnet(
resolvedRegion: string,
subnetName: string,
terminal: Terminal,
vnetName: string,
vNetSubscriptionId: string,
vNetResourceGroup: string
): Promise<any> {
const vNetConfigPayload = {
location: resolvedRegion,
properties: {
addressSpace: {
addressPrefixes: [DEFAULT_VNET_ADDRESS_PREFIX],
},
subnets: [
{
name: subnetName,
properties: {
addressPrefix: DEFAULT_SUBNET_ADDRESS_PREFIX,
delegations: [
{
name: "CloudShellDelegation",
properties: {
serviceName: "Microsoft.ContainerInstance/containerGroups"
}
}
],
},
},
],
},
};
terminal.writeln(terminalLog.vnet(`Creating VNet: ${vnetName}`));
let vNetResponse = await updateVnet<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}`,
vNetConfigPayload
);
while (vNetResponse?.properties?.provisioningState !== "Succeeded") {
vNetResponse = await getVnet<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}`
);
const vNetState = vNetResponse?.properties?.provisioningState;
if (vNetState !== "Succeeded" && vNetState !== "Failed") {
await wait(POLLING_INTERVAL_MS);
terminal.writeln(terminalLog.progress("VNet deployment", vNetState));
} else {
break;
}
}
terminal.writeln(terminalLog.success("VNet created successfully"));
return vNetConfigPayload;
}
/**
* Creates a Network Profile for CloudShell
*/
private static async createNetworkProfileWithVnet(
vNetSubscriptionId: string,
vNetResourceGroup: string,
vnetName: string,
subnetName: string,
resolvedRegion: string,
terminal: Terminal,
networkProfileName: string
): Promise<void> {
const subnetId = `/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/virtualNetworks/${vnetName}/subnets/${subnetName}`;
const createNetworkProfilePayload = {
location: resolvedRegion,
properties: {
containerNetworkInterfaceConfigurations: [
{
name: 'defaultContainerNicConfig',
properties: {
ipConfigurations: [
{
name: 'defaultContainerIpConfig',
properties: {
subnet: {
id: subnetId,
}
}
}
]
}
}
]
}
};
terminal.writeln(terminalLog.vnet("Creating Network Profile"));
let networkProfileResponse = await createNetworkProfile<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}`,
createNetworkProfilePayload
);
while (networkProfileResponse?.properties?.provisioningState !== "Succeeded") {
networkProfileResponse = await getNetworkProfileInfo<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}`
);
const networkProfileState = networkProfileResponse?.properties?.provisioningState;
if (networkProfileState !== "Succeeded" && networkProfileState !== "Failed") {
await wait(POLLING_INTERVAL_MS);
terminal.writeln(terminalLog.progress("Network Profile", networkProfileState));
} else {
break;
}
}
terminal.writeln(terminalLog.success("Network Profile created successfully"));
}
/**
* Creates a Network Relay for CloudShell
*/
private static async createNetworkRelay(
resolvedRegion: string,
terminal: Terminal,
relayName: string,
vNetSubscriptionId: string,
vNetResourceGroup: string
): Promise<void> {
const relayPayload = {
location: resolvedRegion,
sku: {
name: STANDARD_SKU,
tier: STANDARD_SKU,
}
};
terminal.writeln(terminalLog.vnet("Creating Relay Namespace"));
let relayResponse = await createRelay<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`,
relayPayload
);
while (relayResponse?.properties?.provisioningState !== "Succeeded") {
relayResponse = await getRelay<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`
);
const relayState = relayResponse?.properties?.provisioningState;
if (relayState !== "Succeeded" && relayState !== "Failed") {
await wait(POLLING_INTERVAL_MS);
terminal.writeln(terminalLog.progress("Relay Namespace", relayState));
} else {
break;
}
}
terminal.writeln(terminalLog.success("Relay Namespace created successfully"));
}
/**
* Assigns a role to a Network Profile
*/
private static async assignRoleToNetworkProfile(
azureContainerInstanceOID: string,
vNetSubscriptionId: string,
terminal: Terminal,
networkProfileName: string,
vNetResourceGroup: string
): Promise<void> {
const nfRoleName = uuidv4();
const networkProfileRoleAssignmentPayload = {
properties: {
principalId: azureContainerInstanceOID,
roleDefinitionId: `/subscriptions/${vNetSubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/4d97b98b-1d4f-4787-a291-c67834d212e7`
}
};
terminal.writeln(terminalLog.info("Assigning permissions to Network Profile"));
await createRoleOnNetworkProfile<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfileName}/providers/Microsoft.Authorization/roleAssignments/${nfRoleName}`,
networkProfileRoleAssignmentPayload
);
terminal.writeln(terminalLog.success("Network Profile permissions assigned"));
}
/**
* Assigns a role to a Network Relay
*/
private static async assignRoleToRelay(
azureContainerInstanceOID: string,
vNetSubscriptionId: string,
terminal: Terminal,
relayName: string,
vNetResourceGroup: string
): Promise<void> {
const relayRoleName = uuidv4();
const relayRoleAssignmentPayload = {
properties: {
principalId: azureContainerInstanceOID,
roleDefinitionId: `/subscriptions/${vNetSubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c`,
}
};
terminal.writeln(terminalLog.info("Assigning permissions to Relay Namespace"));
await createRoleOnRelay<any>(
`/subscriptions/${vNetSubscriptionId}/resourceGroups/${vNetResourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}/providers/Microsoft.Authorization/roleAssignments/${relayRoleName}`,
relayRoleAssignmentPayload
);
terminal.writeln(terminalLog.success("Relay Namespace permissions assigned"));
}
}

View File

@@ -0,0 +1,80 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Cassandra shell type handler
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { setShellType } from "../Data/CloudShellApiClient";
import { NetworkAccessHandler } from "../Network/NetworkAccessHandler";
import { getHostFromUrl } from "../Utils/CommonUtils";
import { ShellTypeConfig } from "./ShellTypeFactory";
export class CassandraShellHandler implements ShellTypeConfig {
private shellType: TerminalKind = TerminalKind.Cassandra;
constructor() {
setShellType(this.shellType);
}
public getShellName(): string {
return "Cassandra";
}
public async getInitialCommands(): Promise<string> {
const dbAccount = userContext.databaseAccount;
const endpoint = dbAccount.properties.cassandraEndpoint;
// Get database key
const dbName = dbAccount.name;
let key = "";
if (dbName) {
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
key = keys?.primaryMasterKey || "";
}
const config = {
host: getHostFromUrl(endpoint),
name: dbAccount.name,
password: key,
endpoint: endpoint
};
return this.getCommands(config).join("\n").concat("\n");
}
public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{
vNetSettings: any;
isAllPublicAccessEnabled: boolean;
}> {
return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType);
}
private getCommands(config: any): string[] {
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if cqlsh is installed; if not, proceed with installation
"if ! command -v cqlsh &> /dev/null; then echo '⚠️ cqlsh not found. Installing...'; fi",
// 3. Download Cassandra if not installed
"if ! command -v cqlsh &> /dev/null; then curl -LO https://archive.apache.org/dist/cassandra/5.0.3/apache-cassandra-5.0.3-bin.tar.gz; fi",
// 4. Extract Cassandra package if not installed
"if ! command -v cqlsh &> /dev/null; then tar -xvzf apache-cassandra-5.0.3-bin.tar.gz; fi",
// 5. Move Cassandra binaries if not installed
"if ! command -v cqlsh &> /dev/null; then mkdir -p ~/cassandra && mv apache-cassandra-5.0.3/* ~/cassandra/; fi",
// 6. Add Cassandra to PATH if not installed
"if ! command -v cqlsh &> /dev/null; then echo 'export PATH=$HOME/cassandra/bin:$PATH' >> ~/.bashrc; fi",
// 7. Set environment variables for SSL
"if ! command -v cqlsh &> /dev/null; then echo 'export SSL_VERSION=TLSv1_2' >> ~/.bashrc; fi",
"if ! command -v cqlsh &> /dev/null; then echo 'export SSL_VALIDATE=false' >> ~/.bashrc; fi",
// 8. Source .bashrc to update PATH (even if cqlsh was already installed)
"source ~/.bashrc",
// 9. Verify cqlsh installation
"cqlsh --version",
// 10. Login to Cassandra
`cqlsh ${config.host} 10350 -u ${config.name} -p ${config.password} --ssl --protocol-version=4`
];
}
}

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Mongo shell type handler
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { setShellType } from "../Data/CloudShellApiClient";
import { NetworkAccessHandler } from "../Network/NetworkAccessHandler";
import { getHostFromUrl } from "../Utils/CommonUtils";
import { ShellTypeConfig } from "./ShellTypeFactory";
export class MongoShellHandler implements ShellTypeConfig {
private shellType: TerminalKind = TerminalKind.Mongo;
constructor() {
setShellType(this.shellType);
}
public getShellName(): string {
return "MongoDB";
}
public async getInitialCommands(): Promise<string> {
const dbAccount = userContext.databaseAccount;
const endpoint = dbAccount.properties.mongoEndpoint;
// Get database key
const dbName = dbAccount.name;
let key = "";
if (dbName) {
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
key = keys?.primaryMasterKey || "";
}
const config = {
host: getHostFromUrl(endpoint),
name: dbAccount.name,
password: key,
endpoint: endpoint
};
return this.getCommands(config).join("\n").concat("\n");
}
public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{
vNetSettings: any;
isAllPublicAccessEnabled: boolean;
}> {
return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType);
}
private getCommands(config: any): string[] {
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if mongosh is installed; if not, proceed with installation
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
// 3. Download mongosh if not installed
"if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz; fi",
// 4. Extract mongosh package if not installed
"if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-2.3.8-linux-x64.tgz; fi",
// 5. Move mongosh binaries if not installed
"if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/; fi",
// 6. Add mongosh to PATH if not installed
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
// 7. Source .bashrc to update PATH (even if mongosh was already installed)
"source ~/.bashrc",
// 8. Verify mongosh installation
"mongosh --version",
// 9. Login to MongoDB
`mongosh --host ${config.host} --port 10255 --username ${config.name} --password ${config.password} --tls --tlsAllowInvalidCertificates`
];
}
}

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* PostgreSQL shell type handler
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { setShellType } from "../Data/CloudShellApiClient";
import { NetworkAccessHandler } from "../Network/NetworkAccessHandler";
import { getHostFromUrl } from "../Utils/CommonUtils";
import { ShellTypeConfig } from "./ShellTypeFactory";
export class PostgresShellHandler implements ShellTypeConfig {
private shellType: TerminalKind = TerminalKind.Postgres;
constructor() {
setShellType(this.shellType);
}
public getShellName(): string {
return "PostgreSQL";
}
public async getInitialCommands(): Promise<string> {
const dbAccount = userContext.databaseAccount;
const endpoint = dbAccount.properties.postgresqlEndpoint;
// Get database key
const dbName = dbAccount.name;
let key = "";
if (dbName) {
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
key = keys?.primaryMasterKey || "";
}
const config = {
host: getHostFromUrl(endpoint),
name: dbAccount.name,
password: key,
endpoint: endpoint
};
return this.getCommands(config).join("\n").concat("\n");
}
public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{
vNetSettings: any;
isAllPublicAccessEnabled: boolean;
}> {
return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType);
}
private getCommands(config: any): string[] {
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if psql is installed; if not, proceed with installation
"if ! command -v psql &> /dev/null; then echo '⚠️ psql not found. Installing...'; fi",
// 3. Download PostgreSQL if not installed
"if ! command -v psql &> /dev/null; then curl -LO https://ftp.postgresql.org/pub/source/v15.2/postgresql-15.2.tar.bz2; fi",
// 4. Extract PostgreSQL package if not installed
"if ! command -v psql &> /dev/null; then tar -xvjf postgresql-15.2.tar.bz2; fi",
// 5. Create a directory for PostgreSQL installation if not installed
"if ! command -v psql &> /dev/null; then mkdir -p ~/pgsql; fi",
// 6. Download readline (dependency for PostgreSQL) if not installed
"if ! command -v psql &> /dev/null; then curl -LO https://ftp.gnu.org/gnu/readline/readline-8.1.tar.gz; fi",
// 7. Extract readline package if not installed
"if ! command -v psql &> /dev/null; then tar -xvzf readline-8.1.tar.gz; fi",
// 8. Configure readline if not installed
"if ! command -v psql &> /dev/null; then cd readline-8.1 && ./configure --prefix=$HOME/pgsql; fi",
// 9. Add PostgreSQL to PATH if not installed
"if ! command -v psql &> /dev/null; then echo 'export PATH=$HOME/pgsql/bin:$PATH' >> ~/.bashrc; fi",
// 10. Source .bashrc to update PATH (even if psql was already installed)
"source ~/.bashrc",
// 11. Verify PostgreSQL installation
"psql --version",
`psql 'read -p "Enter Database Name: " dbname && read -p "Enter Username: " username && host=${config.endpoint} port=5432 dbname=$dbname user=$username sslmode=require'`
];
}
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Factory for creating shell type handlers
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { CassandraShellHandler } from "./CassandraShellHandler";
import { MongoShellHandler } from "./MongoShellHandler";
import { PostgresShellHandler } from "./PostgresShellHandler";
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
export interface ShellTypeConfig {
getShellName(): string;
getInitialCommands(): Promise<string>;
configureNetworkAccess(terminal: Terminal, region: string): Promise<{
vNetSettings: any;
isAllPublicAccessEnabled: boolean;
}>;
}
export class ShellTypeHandler {
/**
* Gets the appropriate handler for the given shell type
*/
public static getHandler(shellType: TerminalKind): ShellTypeConfig {
switch (shellType) {
case TerminalKind.Postgres:
return new PostgresShellHandler();
case TerminalKind.Mongo:
return new MongoShellHandler();
case TerminalKind.VCoreMongo:
return new VCoreMongoShellHandler();
case TerminalKind.Cassandra:
return new CassandraShellHandler();
default:
throw new Error(`Unsupported shell type: ${shellType}`);
}
}
/**
* Gets the display name for a shell type
*/
public static getShellNameForDisplay(terminalKind: TerminalKind): string {
switch (terminalKind) {
case TerminalKind.Postgres:
return "PostgreSQL";
case TerminalKind.Mongo:
case TerminalKind.VCoreMongo:
return "MongoDB";
case TerminalKind.Cassandra:
return "Cassandra";
default:
return "";
}
}
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* VCore MongoDB shell type handler
*/
import { Terminal } from "xterm";
import { TerminalKind } from "../../../../Contracts/ViewModels";
import { userContext } from "../../../../UserContext";
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { setShellType } from "../Data/CloudShellApiClient";
import { NetworkAccessHandler } from "../Network/NetworkAccessHandler";
import { getHostFromUrl } from "../Utils/CommonUtils";
import { ShellTypeConfig } from "./ShellTypeFactory";
export class VCoreMongoShellHandler implements ShellTypeConfig {
private shellType: TerminalKind = TerminalKind.VCoreMongo;
constructor() {
setShellType(this.shellType);
}
public getShellName(): string {
return "MongoDB VCore";
}
public async getInitialCommands(): Promise<string> {
const dbAccount = userContext.databaseAccount;
const endpoint = dbAccount.properties.vcoreMongoEndpoint;
// Get database key
const dbName = dbAccount.name;
let key = "";
if (dbName) {
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
key = keys?.primaryMasterKey || "";
}
const config = {
host: getHostFromUrl(endpoint),
name: dbAccount.name,
password: key,
endpoint: endpoint
};
return this.getCommands(config).join("\n").concat("\n");
}
public async configureNetworkAccess(terminal: Terminal, region: string): Promise<{
vNetSettings: any;
isAllPublicAccessEnabled: boolean;
}> {
// VCore MongoDB uses private endpoints
return await NetworkAccessHandler.configureNetworkAccess(terminal, region, this.shellType);
}
private getCommands(config: any): string[] {
return [
// 1. Fetch and display location details in a readable format
"curl -s https://ipinfo.io | jq -r '\"Region: \" + .region + \" Country: \" + .country + \" City: \" + .city + \" IP Addr: \" + .ip'",
// 2. Check if mongosh is installed; if not, proceed with installation
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
// 3. Download mongosh if not installed
"if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz; fi",
// 4. Extract mongosh package if not installed
"if ! command -v mongosh &> /dev/null; then tar -xvzf mongosh-2.3.8-linux-x64.tgz; fi",
// 5. Move mongosh binaries if not installed
"if ! command -v mongosh &> /dev/null; then mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/; fi",
// 6. Add mongosh to PATH if not installed
"if ! command -v mongosh &> /dev/null; then echo 'export PATH=$HOME/mongosh/bin:$PATH' >> ~/.bashrc; fi",
// 7. Source .bashrc to update PATH (even if mongosh was already installed)
"source ~/.bashrc",
// 8. Verify mongosh installation
"mongosh --version",
// 9. Login to MongoDB
`read -p "Enter username: " username && mongosh "mongodb+srv://$username:@${config.endpoint}/?authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000" --tls --tlsAllowInvalidCertificates`
];
}
}

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