mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-27 04:41:48 +00:00
Compare commits
23 Commits
users/sour
...
users/sour
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e38201c4 | ||
|
|
80edb66fbf | ||
|
|
c62e89228e | ||
|
|
d68bf02a0e | ||
|
|
62e90ed26d | ||
|
|
1f4e79f856 | ||
|
|
9db1edca03 | ||
|
|
8b4eaa95ea | ||
|
|
10b0da2190 | ||
|
|
4313d6ecbd | ||
|
|
83eafd4485 | ||
|
|
44e85647e4 | ||
|
|
ec891671b6 | ||
|
|
942de980c3 | ||
|
|
2c3c4e7db7 | ||
|
|
9b2cb8a1a9 | ||
|
|
41439cc7d4 | ||
|
|
ce08ce05f2 | ||
|
|
323276beff | ||
|
|
1678ec0a23 | ||
|
|
0babb1fa13 | ||
|
|
78c8df0904 | ||
|
|
76742455bf |
@@ -11,9 +11,3 @@ pool:
|
||||
|
||||
steps:
|
||||
- task: ComponentGovernanceComponentDetection@0
|
||||
inputs:
|
||||
scanType: 'Register'
|
||||
verbosity: 'Verbose'
|
||||
sourceScanPath: 'manifest'
|
||||
detectorsFilter: 'cgmanifest'
|
||||
alertWarningLevel: 'Low'
|
||||
|
||||
@@ -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
|
||||
*************************************************************************************************/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
57
package-lock.json
generated
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
37051
preview/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
export enum FabricMessageTypes {
|
||||
GetAuthorizationToken = "GetAuthorizationToken",
|
||||
GetAllResourceTokens = "GetAllResourceTokens",
|
||||
GetAccessToken = "GetAccessToken",
|
||||
Ready = "Ready",
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">* </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}`}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
) {
|
||||
|
||||
@@ -36,10 +36,6 @@
|
||||
&:active {
|
||||
background-color:@NotificationHigh;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
.focusedBorder();
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
.dataTypeIcons {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import Explorer from "../../Explorer";
|
||||
import Explorer from "../Explorer";
|
||||
import { AddCollectionPanel } from "./AddCollectionPanel";
|
||||
|
||||
const props = {
|
||||
@@ -21,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">* </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">* </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">* </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. It’s critical to choose a field that will evenly distribute your data.";
|
||||
}
|
||||
|
||||
let tooltipText = `The ${this.getPartitionKeyName(
|
||||
true,
|
||||
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
|
||||
}
|
||||
|
||||
return tooltipText;
|
||||
}
|
||||
|
||||
private getPartitionKey(): string {
|
||||
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
|
||||
return "";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault) {
|
||||
return userContext.apiType === "SQL" ? "/id" : "_id";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault2) {
|
||||
return userContext.apiType === "SQL" ? "/pk" : "pk";
|
||||
}
|
||||
if (this.props.isQuickstart) {
|
||||
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private getPartitionKeySubtext(): string {
|
||||
if (
|
||||
userContext.features.partitionKeyDefault &&
|
||||
@@ -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
|
||||
? {
|
||||
@@ -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. It’s critical to choose a field that will evenly distribute your data.";
|
||||
}
|
||||
|
||||
let tooltipText = `The ${getPartitionKeyName(
|
||||
true,
|
||||
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
|
||||
}
|
||||
|
||||
return tooltipText;
|
||||
}
|
||||
|
||||
export function getPartitionKeyName(isLowerCase?: boolean): string {
|
||||
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
|
||||
|
||||
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
|
||||
}
|
||||
|
||||
export function getPartitionKeyPlaceHolder(index?: number): string {
|
||||
switch (userContext.apiType) {
|
||||
case "Mongo":
|
||||
return "e.g., categoryId";
|
||||
case "Gremlin":
|
||||
return "e.g., /address";
|
||||
case "SQL":
|
||||
return `${
|
||||
index === undefined
|
||||
? "Required - first partition key e.g., /TenantId"
|
||||
: index === 0
|
||||
? "second partition key e.g., /UserId"
|
||||
: "third partition key e.g., /SessionId"
|
||||
}`;
|
||||
default:
|
||||
return "e.g., /address/zipCode";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPartitionKey(isQuickstart?: boolean): string {
|
||||
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
|
||||
return "";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault) {
|
||||
return userContext.apiType === "SQL" ? "/id" : "_id";
|
||||
}
|
||||
if (userContext.features.partitionKeyDefault2) {
|
||||
return userContext.apiType === "SQL" ? "/pk" : "pk";
|
||||
}
|
||||
if (isQuickstart) {
|
||||
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isFreeTierAccount(): boolean {
|
||||
return userContext.databaseAccount?.properties?.enableFreeTier;
|
||||
}
|
||||
|
||||
export function UniqueKeysHeader(): JSX.Element {
|
||||
const tooltipContent =
|
||||
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key.";
|
||||
|
||||
return (
|
||||
<Stack horizontal>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Unique keys
|
||||
</Text>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
|
||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowAnalyticalStoreOptions(): boolean {
|
||||
if (isFabricNative() || configContext.platform === Platform.Emulator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "SQL":
|
||||
case "Mongo":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AnalyticalStorageContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
|
||||
the performance of transactional workloads.{" "}
|
||||
<Link
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
|
||||
target="_blank"
|
||||
href="https://aka.ms/analytical-store-overview"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function isSynapseLinkEnabled(): boolean {
|
||||
if (!userContext.databaseAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { properties } = userContext.databaseAccount;
|
||||
if (!properties) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (properties.enableAnalyticalStorage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return properties.capabilities?.some(
|
||||
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
|
||||
);
|
||||
}
|
||||
|
||||
export function scrollToSection(id: string): void {
|
||||
document.getElementById(id)?.scrollIntoView();
|
||||
}
|
||||
|
||||
export function ContainerVectorPolicyTooltipContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Describe any properties in your data that contain vectors, so that they can be made available for similarity
|
||||
queries.{" "}
|
||||
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseUniqueKeys(uniqueKeys: string[]): DataModels.UniqueKeyPolicy {
|
||||
if (uniqueKeys?.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] };
|
||||
uniqueKeys.forEach((uniqueKey: string) => {
|
||||
if (uniqueKey) {
|
||||
const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0);
|
||||
const trimmedPaths: string[] = validPaths?.map((path) => path.trim());
|
||||
if (trimmedPaths?.length > 0) {
|
||||
if (userContext.apiType === "Mongo") {
|
||||
trimmedPaths.map((path) => {
|
||||
const transformedPath = path.split(".").join("/");
|
||||
if (transformedPath[0] !== "/") {
|
||||
return "/" + transformedPath;
|
||||
}
|
||||
return transformedPath;
|
||||
});
|
||||
}
|
||||
uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueKeyPolicy;
|
||||
}
|
||||
|
||||
export const SharedDatabaseDefault: DataModels.IndexingPolicy = {
|
||||
indexingMode: "consistent",
|
||||
automatic: true,
|
||||
includedPaths: [],
|
||||
excludedPaths: [
|
||||
{
|
||||
path: "/*",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const FullTextPolicyDefault: DataModels.FullTextPolicy = {
|
||||
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
|
||||
fullTextPaths: [],
|
||||
};
|
||||
|
||||
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
|
||||
indexingMode: "consistent",
|
||||
automatic: true,
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/*",
|
||||
indexes: [
|
||||
{
|
||||
kind: "Range",
|
||||
dataType: "Number",
|
||||
precision: -1,
|
||||
},
|
||||
{
|
||||
kind: "Range",
|
||||
dataType: "String",
|
||||
precision: -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
excludedPaths: [],
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Source container id
|
||||
</Text>
|
||||
</Stack>
|
||||
<Dropdown
|
||||
placeholder="Choose source container"
|
||||
options={sourceContainerOptions}
|
||||
defaultSelectedKey={selectedSourceContainer?.rid}
|
||||
styles={chooseSourceContainerStyles()}
|
||||
style={chooseSourceContainerStyle()}
|
||||
onChange={(_, options: IDropdownOption) => setSelectedSourceContainer(options.data as Collection)}
|
||||
/>
|
||||
<Separator className="panelSeparator" />
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Global secondary index container id
|
||||
</Text>
|
||||
</Stack>
|
||||
<input
|
||||
id="globalSecondaryIndexId"
|
||||
type="text"
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., indexbyEmailId`}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
value={globalSecondaryIndexId}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGlobalSecondaryIndexId(event.target.value)}
|
||||
/>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Global secondary index definition
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={
|
||||
<Link
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
|
||||
target="blank"
|
||||
>
|
||||
Learn more about defining global secondary indexes.
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Icon role="button" iconName="Info" className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
<input
|
||||
id="globalSecondaryIndexDefinition"
|
||||
type="text"
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder={"SELECT c.email, c.accountId FROM c"}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
value={definition || ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDefinition(event.target.value)}
|
||||
/>
|
||||
<PartitionKeyComponent
|
||||
{...{ partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 }}
|
||||
/>
|
||||
<ThroughputComponent
|
||||
{...{
|
||||
enableDedicatedThroughput,
|
||||
setEnabledDedicatedThroughput,
|
||||
isSelectedSourceContainerSharedThroughput,
|
||||
showCollectionThroughputInput,
|
||||
globalSecondaryIndexThroughputOnChange,
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">* </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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
padding-left: @MediumSpace;
|
||||
|
||||
.paneErrorLink {
|
||||
color: @LinkColor;
|
||||
cursor: pointer;
|
||||
font-size: @mediumFontSize;
|
||||
}
|
||||
|
||||
@@ -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: </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>
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 we’ll 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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
126
src/Explorer/Tabs/CloudShellTab/AttachAddOn.tsx
Normal file
126
src/Explorer/Tabs/CloudShellTab/AttachAddOn.tsx
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
76
src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx
Normal file
76
src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx
Normal 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"}} />;
|
||||
};
|
||||
152
src/Explorer/Tabs/CloudShellTab/Commands.tsx
Normal file
152
src/Explorer/Tabs/CloudShellTab/Commands.tsx
Normal 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 "";
|
||||
}
|
||||
};
|
||||
393
src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx
Normal file
393
src/Explorer/Tabs/CloudShellTab/Core/CloudShellTerminalCore.tsx
Normal 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);
|
||||
};
|
||||
}
|
||||
};
|
||||
320
src/Explorer/Tabs/CloudShellTab/Data.tsx
Normal file
320
src/Explorer/Tabs/CloudShellTab/Data.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
263
src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx
Normal file
263
src/Explorer/Tabs/CloudShellTab/Data/CloudShellApiClient.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
12
src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx
Normal file
12
src/Explorer/Tabs/CloudShellTab/Data/LocalizationUtils.tsx
Normal 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');
|
||||
};
|
||||
37
src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx
Normal file
37
src/Explorer/Tabs/CloudShellTab/Data/RegionUtils.tsx
Normal 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;
|
||||
};
|
||||
185
src/Explorer/Tabs/CloudShellTab/DataModels.tsx
Normal file
185
src/Explorer/Tabs/CloudShellTab/DataModels.tsx
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
29
src/Explorer/Tabs/CloudShellTab/LogFormatter.tsx
Normal file
29
src/Explorer/Tabs/CloudShellTab/LogFormatter.tsx
Normal 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`
|
||||
};
|
||||
74
src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts
Normal file
74
src/Explorer/Tabs/CloudShellTab/Models/ApiVersions.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
163
src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx
Normal file
163
src/Explorer/Tabs/CloudShellTab/Models/DataModels.tsx
Normal 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"
|
||||
}
|
||||
94
src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx
Normal file
94
src/Explorer/Tabs/CloudShellTab/Network/FirewallHandler.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
894
src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx
Normal file
894
src/Explorer/Tabs/CloudShellTab/Network/VNetHandler.tsx
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'`
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user