import { ChoiceGroup, DefaultButton, DirectionalHint, Dropdown, IChoiceGroupOption, IDropdownOption, Icon, IconButton, Link, MessageBar, MessageBarType, PrimaryButton, Stack, Text, TooltipHost, } from "@fluentui/react"; import { CapabilityNames } from "Common/Constants"; import * as Constants from "Common/Constants"; import { handleError } from "Common/ErrorHandlingUtils"; import LoadingOverlay from "Common/LoadingOverlay"; import { logError } from "Common/Logger"; import { createCollection } from "Common/dataAccess/createCollection"; import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers"; import * as DataModels from "Contracts/DataModels"; import * as ViewModels from "Contracts/ViewModels"; import { getPartitionKeyName, getPartitionKeyPlaceHolder, getPartitionKeySubtext, getPartitionKeyTooltipText, } from "Explorer/Controls/Settings/SettingsUtils"; import ContainerCopyMessages from "Explorer/ContainerCopy/ContainerCopyMessages"; import { BackupPolicyType, CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; import Explorer from "Explorer/Explorer"; import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm"; import { useDatabases } from "Explorer/useDatabases"; import { Keys, t } from "Localization"; import { userContext } from "UserContext"; import { getCollectionName } from "Utils/APITypeUtils"; import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; import { update as updateDatabaseAccount } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useSidePanel } from "hooks/useSidePanel"; import * as React from "react"; export interface ChangePartitionKeyPaneProps { sourceDatabase: ViewModels.Database; sourceCollection: ViewModels.Collection; explorer: Explorer; onClose: () => Promise; } const migrationTypeOptions: IChoiceGroupOption[] = [ { key: CopyJobMigrationType.Offline, text: ContainerCopyMessages.migrationTypeOptions.offline.title, styles: { root: { width: "33%" } }, }, { key: CopyJobMigrationType.Online, text: ContainerCopyMessages.migrationTypeOptions.online.title, styles: { root: { width: "33%" } }, }, ]; const choiceGroupStyles = { flexContainer: { display: "flex" as const }, root: { selectors: { ".ms-ChoiceField": { color: "var(--colorNeutralForeground1)", }, ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { color: "var(--colorNeutralForeground1)", }, }, }, }; const checkPitrEnabled = (account: DataModels.DatabaseAccount): boolean => { return account?.properties?.backupPolicy?.type === BackupPolicyType.Continuous; }; const checkOnlineCopyEnabled = (account: DataModels.DatabaseAccount): boolean => { const capabilities = account?.properties?.capabilities ?? []; return capabilities.some((cap) => cap.name === CapabilityNames.EnableOnlineCopyFeature); }; export const ChangePartitionKeyPane: React.FC = ({ sourceDatabase, sourceCollection, explorer, onClose, }) => { const [targetCollectionId, setTargetCollectionId] = React.useState(); const [createNewContainer, setCreateNewContainer] = React.useState(true); const [formError, setFormError] = React.useState(); const [isExecuting, setIsExecuting] = React.useState(false); const [subPartitionKeys, setSubPartitionKeys] = React.useState([]); const [partitionKey, setPartitionKey] = React.useState(); const [migrationType, setMigrationType] = React.useState(CopyJobMigrationType.Offline); // Pane-local account state for tracking prerequisite enablement const [localAccount, setLocalAccount] = React.useState(userContext.databaseAccount); const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState(false); const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState(""); const pitrEnabled = checkPitrEnabled(localAccount); const onlineCopyFeatureEnabled = checkOnlineCopyEnabled(localAccount); const onlinePrerequisitesMet = pitrEnabled && onlineCopyFeatureEnabled; const isOnlineMode = migrationType === CopyJobMigrationType.Online; const accountName = localAccount?.name ?? ""; const subscriptionId = userContext.subscriptionId; const resourceGroup = userContext.resourceGroup; const intervalRef = React.useRef(null); const timeoutRef = React.useRef(null); React.useEffect(() => { return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const refreshAccount = async (): Promise => { try { const account = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName); if (account) { setLocalAccount(account); } return account; } catch (error) { logError( error.message || "Error fetching account after enabling prerequisite.", "ChangePartitionKey/refreshAccount", ); return null; } }; const clearPollingTimers = () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }; const startPollingForAccountUpdate = () => { intervalRef.current = setInterval(() => { refreshAccount(); }, 30 * 1000); timeoutRef.current = setTimeout( () => { clearPollingTimers(); setIsEnablingPrerequisite(false); }, 10 * 60 * 1000, ); }; const handleEnablePitr = () => { const featureUrl = `https://portal.azure.com/#@/resource/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/backupRestore`; setIsEnablingPrerequisite(true); setPrerequisiteLoaderMessage(ContainerCopyMessages.popoverOverlaySpinnerLabel); window.open(featureUrl, "_blank"); startPollingForAccountUpdate(); }; const handleEnableOnlineCopy = async () => { setIsEnablingPrerequisite(true); try { setPrerequisiteLoaderMessage( ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel, ); const currentAccount = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName); if (!currentAccount?.properties?.enableAllVersionsAndDeletesChangeFeed) { setPrerequisiteLoaderMessage( ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel, ); await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, { properties: { enableAllVersionsAndDeletesChangeFeed: true, }, }); } const capabilities = currentAccount?.properties?.capabilities ?? []; setPrerequisiteLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(accountName)); await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, { properties: { capabilities: [...capabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], }, }); startPollingForAccountUpdate(); } catch (error) { logError(error.message || "Failed to enable online copy feature.", "ChangePartitionKey/handleEnableOnlineCopy"); setFormError("Failed to enable online copy feature. Please try again."); setIsEnablingPrerequisite(false); } }; const getCollectionOptions = (): IDropdownOption[] => { return sourceDatabase .collections() .filter((collection) => collection.id !== sourceCollection.id) .map((collection) => ({ key: collection.id(), text: collection.id(), })); }; const submit = async () => { if (!validateInputs()) { return; } setIsExecuting(true); try { createNewContainer && (await createContainer()); await createDataTransferJob(); await onClose(); } catch (error) { handleError(error, "ChangePartitionKey", t(Keys.panes.changePartitionKey.failedToStartError)); } setIsExecuting(false); useSidePanel.getState().closeSidePanel(); }; const validateInputs = (): boolean => { if (!createNewContainer && !targetCollectionId) { setFormError("Choose an existing container"); return false; } if (isOnlineMode && !onlinePrerequisitesMet) { setFormError("Online migration prerequisites must be enabled before proceeding."); return false; } return true; }; const getModeForApi = (): "Offline" | "Online" => { return migrationType === CopyJobMigrationType.Online ? "Online" : "Offline"; }; const createDataTransferJob = async () => { const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`; const dataTransferParams: DataTransferParams = { jobName, apiType: userContext.apiType, subscriptionId: userContext.subscriptionId, resourceGroupName: userContext.resourceGroup, accountName: userContext.databaseAccount.name, sourceDatabaseName: sourceDatabase.id(), sourceCollectionName: sourceCollection.id(), targetDatabaseName: sourceDatabase.id(), targetCollectionName: targetCollectionId, mode: getModeForApi(), }; await initiateDataTransfer(dataTransferParams); }; const createContainer = async () => { const partitionKeyString = partitionKey.trim(); const partitionKeyData: DataModels.PartitionKey = partitionKeyString ? { paths: [partitionKeyString, ...(subPartitionKeys.length > 0 ? subPartitionKeys : [])], kind: subPartitionKeys.length > 0 ? "MultiHash" : "Hash", version: 2, } : undefined; const createCollectionParams: DataModels.CreateCollectionParams = { createNewDatabase: false, collectionId: targetCollectionId, databaseId: sourceDatabase.id(), databaseLevelThroughput: isSelectedDatabaseSharedThroughput(), offerThroughput: sourceCollection.offer()?.manualThroughput, autoPilotMaxThroughput: sourceCollection.offer()?.autoscaleMaxThroughput, partitionKey: partitionKeyData, }; await createCollection(createCollectionParams); await explorer.refreshAllDatabases(); }; const isSelectedDatabaseSharedThroughput = (): boolean => { const selectedDatabase = useDatabases .getState() .databases?.find((database) => database.id() === sourceDatabase.id()); return !!selectedDatabase?.offer(); }; const isSubmitDisabled = isOnlineMode && !onlinePrerequisitesMet; return ( {t(Keys.panes.changePartitionKey.description)}  {t(Keys.common.learnMore)} {/* Migration Type */} Migration type { if (option) { setMigrationType(option.key as CopyJobMigrationType); } }} ariaLabelledBy="migrationTypeChoiceGroup" styles={choiceGroupStyles} /> {migrationType && ( {migrationType === CopyJobMigrationType.Offline ? ContainerCopyMessages.migrationTypeOptions.offline.description : ContainerCopyMessages.migrationTypeOptions.online.description} )} Database id
setCreateNewContainer(true)} /> New container setCreateNewContainer(false)} /> Existing container
{createNewContainer ? ( All configurations except for unique keys will be copied from the source container {`${getCollectionName()} id`} ) => setTargetCollectionId(event.target.value)} /> {getPartitionKeyName(userContext.apiType)} {getPartitionKeySubtext(userContext.features.partitionKeyDefault, userContext.apiType)} ) => { if (!partitionKey && !event.target.value.startsWith("/")) { setPartitionKey("/" + event.target.value); } else { setPartitionKey(event.target.value); } }} /> {subPartitionKeys.map((subPartitionKey: string, index: number) => { return (
0 ? 1 : 0} className="panelTextField" autoComplete="off" placeholder={getPartitionKeyPlaceHolder(userContext.apiType, index)} aria-label={getPartitionKeyName(userContext.apiType)} pattern={".*"} title={""} value={subPartitionKey} onChange={(event: React.ChangeEvent) => { const keys = [...subPartitionKeys]; if (!keys[index] && !event.target.value.startsWith("/")) { keys[index] = "/" + event.target.value.trim(); setSubPartitionKeys(keys); } else { keys[index] = event.target.value.trim(); setSubPartitionKeys(keys); } }} /> { const keys = subPartitionKeys.filter((uniqueKey, j) => index !== j); setSubPartitionKeys(keys); }} />
); })} = Constants.BackendDefaults.maxNumMultiHashPartition} onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])} > {t(Keys.panes.addCollection.addPartitionKey)} {subPartitionKeys.length > 0 && ( {" "} {t(Keys.panes.addCollection.hierarchicalPartitionKeyInfo)}{" "} {t(Keys.common.learnMore)} )}
) : ( {`${getCollectionName()}`} , collection: IDropdownOption) => { setTargetCollectionId(collection.key as string); setFormError(""); }} defaultSelectedKey={targetCollectionId} responsiveMode={999} ariaLabel={t(Keys.panes.changePartitionKey.existingContainers)} /> )} {/* Online prerequisites section */} {isOnlineMode && ( {ContainerCopyMessages.assignPermissions.onlineConfiguration.title} {ContainerCopyMessages.assignPermissions.onlineConfiguration.description(accountName)} {/* Point In Time Restore */} {ContainerCopyMessages.pointInTimeRestore.title} {!pitrEnabled && ( {ContainerCopyMessages.pointInTimeRestore.description(accountName)} )} {/* Online Copy Enabled */} {ContainerCopyMessages.onlineCopyEnabled.title} {!onlineCopyFeatureEnabled && ( {ContainerCopyMessages.onlineCopyEnabled.description(accountName)}  {ContainerCopyMessages.onlineCopyEnabled.hrefText} )} {!onlinePrerequisitesMet && ( Online migration prerequisites must be enabled before proceeding. )} )}
); };