import { ActionButton, Checkbox, DefaultButton, DirectionalHint, Dropdown, Icon, IconButton, IDropdownOption, Link, Separator, Stack, Text, TooltipHost, } from "@fluentui/react"; import * as Constants from "Common/Constants"; import { createCollection } from "Common/dataAccess/createCollection"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { configContext, Platform } from "ConfigContext"; import * as DataModels from "Contracts/DataModels"; import { SubscriptionType } from "Contracts/SubscriptionType"; import { useSidePanel } from "hooks/useSidePanel"; import React 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 { getCollectionName } from "Utils/APITypeUtils"; import { isCapabilityEnabled, isServerlessAccount } from "Utils/CapabilityUtils"; import { getUpsellMessage } from "Utils/PricingUtils"; import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; 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; databaseId?: string; } const SharedDatabaseDefault: DataModels.IndexingPolicy = { indexingMode: "consistent", automatic: true, includedPaths: [], excludedPaths: [ { path: "/*", }, ], }; 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 interface AddCollectionPanelState { createNewDatabase: boolean; newDatabaseId: string; isSharedThroughputChecked: boolean; selectedDatabaseId: string; collectionId: string; enableIndexing: boolean; isSharded: boolean; partitionKey: string; enableDedicatedThroughput: boolean; createMongoWildCardIndex: boolean; useHashV2: boolean; enableAnalyticalStore: boolean; uniqueKeys: string[]; errorMessage: string; showErrorDetails: boolean; isExecuting: boolean; isThroughputCapExceeded: boolean; } export class AddCollectionPanel extends React.Component { private newDatabaseThroughput: number; private isNewDatabaseAutoscale: boolean; private collectionThroughput: number; private isCollectionAutoscale: boolean; private isCostAcknowledged: boolean; constructor(props: AddCollectionPanelProps) { super(props); this.state = { createNewDatabase: userContext.apiType !== "Tables" && !this.props.databaseId, newDatabaseId: "", isSharedThroughputChecked: this.getSharedThroughputDefault(), selectedDatabaseId: userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : this.props.databaseId, collectionId: "", enableIndexing: true, isSharded: userContext.apiType !== "Tables", partitionKey: this.getPartitionKey(), enableDedicatedThroughput: false, createMongoWildCardIndex: isCapabilityEnabled("EnableMongo"), useHashV2: false, enableAnalyticalStore: false, uniqueKeys: [], errorMessage: "", showErrorDetails: false, isExecuting: false, isThroughputCapExceeded: false, }; } render(): JSX.Element { const isFirstResourceCreated = useDatabases.getState().isFirstResourceCreated(); return (
{this.state.errorMessage && ( )} {!this.state.errorMessage && this.isFreeTierAccount() && ( )}
{`${getCollectionName()} ${userContext.apiType === "Mongo" ? "name" : "id"}`} ) => this.setState({ collectionId: event.target.value }) } /> {this.shouldShowIndexingOptionsForFreeTierAccount() && ( Indexing Automatic Off {this.getFreeTierIndexingText()}{" "} Learn more )} {userContext.apiType === "Mongo" && (!this.state.isSharedThroughputChecked || this.props.explorer.isFixedCollectionWithSharedThroughputSupported()) && ( Sharding Unsharded (20GB limit) Sharded )} {this.state.isSharded && ( {this.getPartitionKeyName()} {this.getPartitionKeySubtext()} ) => { if ( userContext.apiType !== "Mongo" && !this.state.partitionKey && !event.target.value.startsWith("/") ) { this.setState({ partitionKey: "/" + event.target.value }); } else { this.setState({ partitionKey: event.target.value }); } }} /> )} {!isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && ( , isChecked: boolean) => this.setState({ enableDedicatedThroughput: isChecked }) } /> )} {this.shouldShowCollectionThroughputInput() && ( (this.collectionThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => this.setState({ isThroughputCapExceeded }) } onCostAcknowledgeChange={(isAcknowledged: boolean) => { this.isCostAcknowledged = isAcknowledged; }} /> )} {userContext.apiType === "SQL" && ( Unique keys {this.state.uniqueKeys.map( (uniqueKey: string, i: number): JSX.Element => { return ( ) => { const uniqueKeys = this.state.uniqueKeys.map((uniqueKey: string, j: number) => { if (i === j) { return event.target.value; } return uniqueKey; }); this.setState({ uniqueKeys }); }} /> { const uniqueKeys = this.state.uniqueKeys.filter((uniqueKey, j) => i !== j); this.setState({ uniqueKeys }); }} /> ); } )} this.setState({ uniqueKeys: [...this.state.uniqueKeys, ""] })} > Add unique key )} {this.shouldShowAnalyticalStoreOptions() && ( Analytical store On Off {!this.isSynapseLinkEnabled() && ( Azure Synapse Link is required for creating an analytical store{" "} {getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account.{" "} Learn more this.props.explorer.openEnableSynapseLinkDialog()} style={{ height: 27, width: 80 }} styles={{ label: { fontSize: 12 } }} /> )} )} {userContext.apiType !== "Tables" && ( { TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection); this.scrollToAdvancedSection(); }} > {isCapabilityEnabled("EnableMongo") && ( Indexing , isChecked: boolean) => this.setState({ createMongoWildCardIndex: isChecked }) } /> )} {userContext.apiType === "SQL" && ( , isChecked: boolean) => this.setState({ useHashV2: isChecked }) } /> )} )}
{this.state.isExecuting && } ); } private getDatabaseOptions(): IDropdownOption[] { return useDatabases.getState().databases?.map((database) => ({ key: database.id(), text: database.id(), })); } private getPartitionKeyName(isLowerCase?: boolean): string { const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key"; return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName; } private getPartitionKeyPlaceHolder(): string { switch (userContext.apiType) { case "Mongo": return "e.g., address.zipCode"; case "Gremlin": return "e.g., /address"; default: return "e.g., /address/zipCode"; } } private onCreateNewDatabaseRadioBtnChange(event: React.ChangeEvent): void { if (event.target.checked && !this.state.createNewDatabase) { this.setState({ createNewDatabase: true, }); } } private onUseExistingDatabaseRadioBtnChange(event: React.ChangeEvent): void { if (event.target.checked && this.state.createNewDatabase) { this.setState({ createNewDatabase: false, }); } } private onUnshardedRadioBtnChange(event: React.ChangeEvent): void { if (event.target.checked && this.state.isSharded) { this.setState({ isSharded: false, }); } } private onShardedRadioBtnChange(event: React.ChangeEvent): void { if (event.target.checked && !this.state.isSharded) { this.setState({ isSharded: true, }); } } private onEnableAnalyticalStoreRadioBtnChange(event: React.ChangeEvent): void { if (event.target.checked && !this.state.enableAnalyticalStore) { this.setState({ enableAnalyticalStore: true, }); } } private onDisableAnalyticalStoreRadioBtnChange(event: React.ChangeEvent): void { if (event.target.checked && this.state.enableAnalyticalStore) { this.setState({ enableAnalyticalStore: false, }); } } private onTurnOnIndexing(event: React.ChangeEvent): void { if (event.target.checked && !this.state.enableIndexing) { this.setState({ enableIndexing: true, }); } } private onTurnOffIndexing(event: React.ChangeEvent): void { if (event.target.checked && this.state.enableIndexing) { this.setState({ enableIndexing: false, }); } } private isSelectedDatabaseSharedThroughput(): boolean { if (!this.state.selectedDatabaseId) { return false; } const selectedDatabase = useDatabases .getState() .databases?.find((database) => database.id() === this.state.selectedDatabaseId); return !!selectedDatabase?.offer(); } private isFreeTierAccount(): boolean { return userContext.databaseAccount?.properties?.enableFreeTier; } private getSharedThroughputDefault(): boolean { return userContext.subscriptionType !== SubscriptionType.EA && !isServerlessAccount(); } 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"; } return ""; } private getPartitionKeySubtext(): string { if ( userContext.features.partitionKeyDefault && (userContext.apiType === "SQL" || userContext.apiType === "Mongo") ) { const subtext = "For small workloads, the item ID is a suitable choice for the partition key."; return subtext; } return ""; } private getAnalyticalStorageTooltipContent(): JSX.Element { return ( Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads.{" "} Learn more ); } private shouldShowCollectionThroughputInput(): boolean { if (isServerlessAccount()) { return false; } if (this.state.createNewDatabase) { return !this.state.isSharedThroughputChecked; } if (this.state.enableDedicatedThroughput) { return true; } return this.state.selectedDatabaseId && !this.isSelectedDatabaseSharedThroughput(); } private shouldShowIndexingOptionsForFreeTierAccount(): boolean { if (!this.isFreeTierAccount()) { return false; } return this.state.createNewDatabase ? this.state.isSharedThroughputChecked : this.isSelectedDatabaseSharedThroughput(); } private shouldShowAnalyticalStoreOptions(): boolean { if (configContext.platform === Platform.Emulator) { return false; } if (isServerlessAccount()) { return false; } switch (userContext.apiType) { case "SQL": case "Mongo": return true; default: return false; } } private isSynapseLinkEnabled(): boolean { const { properties } = userContext.databaseAccount; if (!properties) { return false; } if (properties.enableAnalyticalStorage) { return true; } return properties.capabilities?.some( (capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics ); } private parseUniqueKeys(): DataModels.UniqueKeyPolicy { if (this.state.uniqueKeys?.length === 0) { return undefined; } const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] }; this.state.uniqueKeys.forEach((uniqueKey) => { 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; } private validateInputs(): boolean { if (!this.state.createNewDatabase && !this.state.selectedDatabaseId) { this.setState({ errorMessage: "Please select an existing database" }); return false; } const throughput = this.state.createNewDatabase ? this.newDatabaseThroughput : this.collectionThroughput; if (throughput > CollectionCreation.DefaultCollectionRUs100K && !this.isCostAcknowledged) { const errorMessage = this.isNewDatabaseAutoscale ? "Please acknowledge the estimated monthly spend." : "Please acknowledge the estimated daily spend."; this.setState({ errorMessage }); return false; } if (throughput > CollectionCreation.MaxRUPerPartition && !this.state.isSharded) { this.setState({ errorMessage: "Unsharded collections support up to 10,000 RUs" }); return false; } if ( userContext.apiType === "Gremlin" && (this.state.partitionKey === "/id" || this.state.partitionKey === "/label") ) { this.setState({ errorMessage: "/id and /label as partition keys are not allowed for graph." }); return false; } return true; } private getAnalyticalStorageTtl(): number { if (!this.isSynapseLinkEnabled()) { return undefined; } if (!this.shouldShowAnalyticalStoreOptions()) { return undefined; } if (this.state.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; } private scrollToAdvancedSection(): void { document.getElementById("collapsibleSectionContent")?.scrollIntoView(); } private async submit(event: React.FormEvent): Promise { event.preventDefault(); if (!this.validateInputs()) { return; } const collectionId: string = this.state.collectionId.trim(); let databaseId = this.state.createNewDatabase ? this.state.newDatabaseId.trim() : this.state.selectedDatabaseId; let partitionKeyString = this.state.isSharded ? this.state.partitionKey.trim() : undefined; if (userContext.apiType === "Tables") { // Table require fixed Database: TablesDB, and fixed Partition Key: /'$pk' databaseId = CollectionCreation.TablesAPIDefaultDatabase; partitionKeyString = "/'$pk'"; } const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys(); const partitionKeyVersion = this.state.useHashV2 ? 2 : undefined; const partitionKey: DataModels.PartitionKey = partitionKeyString ? { paths: [partitionKeyString], kind: "Hash", version: partitionKeyVersion, } : undefined; const indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing ? AllPropertiesIndexed : SharedDatabaseDefault; const telemetryData = { database: { id: databaseId, new: this.state.createNewDatabase, shared: this.state.createNewDatabase ? this.state.isSharedThroughputChecked : this.isSelectedDatabaseSharedThroughput(), }, collection: { id: this.state.collectionId, throughput: this.collectionThroughput, isAutoscale: this.isCollectionAutoscale, partitionKey, uniqueKeyPolicy, collectionWithDedicatedThroughput: this.state.enableDedicatedThroughput, }, subscriptionQuotaId: userContext.quotaId, dataExplorerArea: Constants.Areas.ContextualPane, useIndexingForSharedThroughput: this.state.enableIndexing, }; const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData); const databaseLevelThroughput: boolean = this.state.createNewDatabase ? this.state.isSharedThroughputChecked : this.isSelectedDatabaseSharedThroughput() && !this.state.enableDedicatedThroughput; let offerThroughput: number; let autoPilotMaxThroughput: number; if (databaseLevelThroughput) { if (this.state.createNewDatabase) { if (this.isNewDatabaseAutoscale) { autoPilotMaxThroughput = this.newDatabaseThroughput; } else { offerThroughput = this.newDatabaseThroughput; } } } else { if (this.isCollectionAutoscale) { autoPilotMaxThroughput = this.collectionThroughput; } else { offerThroughput = this.collectionThroughput; } } const createCollectionParams: DataModels.CreateCollectionParams = { createNewDatabase: this.state.createNewDatabase, collectionId, databaseId, databaseLevelThroughput, offerThroughput, autoPilotMaxThroughput, analyticalStorageTtl: this.getAnalyticalStorageTtl(), indexingPolicy, partitionKey, uniqueKeyPolicy, createMongoWildcardIndex: this.state.createMongoWildCardIndex, }; this.setState({ isExecuting: true }); try { await createCollection(createCollectionParams); this.setState({ isExecuting: false }); this.props.explorer.refreshAllDatabases(); TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey); useSidePanel.getState().closeSidePanel(); } catch (error) { const errorMessage: string = getErrorMessage(error); this.setState({ isExecuting: false, errorMessage, showErrorDetails: true }); const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) }; TelemetryProcessor.traceFailure(Action.CreateCollection, failureTelemetryData, startKey); } } }