diff --git a/package-lock.json b/package-lock.json index 045e28f44..3969be269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "tinykeys": "2.1.0", "underscore": "1.12.1", "utility-types": "3.10.0", + "uuid": "9.0.0", "zustand": "3.5.0" }, "devDependencies": { @@ -626,6 +627,14 @@ } } }, + "node_modules/@azure/ms-rest-js/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/ms-rest-js/node_modules/xml2js": { "version": "0.5.0", "license": "MIT", @@ -685,6 +694,14 @@ "node": ">=0.8.0" } }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -7595,6 +7612,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/commutable/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/connected-components": { "version": "6.8.2", "license": "BSD-3-Clause", @@ -9125,6 +9150,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/fixtures/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/iron-icons": { "version": "1.0.0", "license": "BSD-3-Clause", @@ -9282,6 +9315,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/messaging/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/monaco-editor": { "version": "3.2.2", "license": "BSD-3-Clause", @@ -9397,6 +9438,14 @@ "version": "0.18.1", "license": "MIT" }, + "node_modules/@nteract/monaco-editor/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/mythic-configuration": { "version": "1.0.12", "license": "BSD-3-Clause", @@ -9665,6 +9714,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/reducers/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/selectors": { "version": "3.2.0", "license": "BSD-3-Clause", @@ -9888,6 +9945,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/types/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "license": "MIT", @@ -26419,6 +26484,15 @@ "xmlbuilder": "^15.1.0" } }, + "node_modules/jest-trx-results-processor/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/jest-util": { "version": "24.9.0", "license": "MIT", @@ -33753,6 +33827,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", @@ -35619,8 +35702,9 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "license": "MIT", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 451da877d..ad7fd5e19 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "@types/mkdirp": "1.0.1", "@types/node-fetch": "2.5.7", "@xmldom/xmldom": "0.7.13", - "@xterm/xterm": "5.5.0", "@xterm/addon-fit": "0.10.0", + "@xterm/xterm": "5.5.0", "allotment": "1.20.2", "applicationinsights": "1.8.0", "bootstrap": "3.4.1", @@ -111,6 +111,7 @@ "tinykeys": "2.1.0", "underscore": "1.12.1", "utility-types": "3.10.0", + "uuid": "9.0.0", "zustand": "3.5.0" }, "devDependencies": { @@ -248,4 +249,4 @@ "printWidth": 120, "endOfLine": "auto" } -} \ No newline at end of file +} diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 74d5ef925..65b3f8d51 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -93,7 +93,7 @@ export class CapabilityNames { public static readonly EnableDataMasking: string = "EnableDataMasking"; public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking"; public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures"; - public static readonly EnableOnlineCopyFeature: string = "EnableOnlineCopyFeature"; + public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy"; } export enum CapacityMode { @@ -297,6 +297,7 @@ export class HttpHeaders { public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput"; public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot"; public static xAPIKey: string = "X-API-Key"; + public static sessionId: string = "x-ms-client-session-id"; } export class ContentType { diff --git a/src/Common/LoadingOverlay.tsx b/src/Common/LoadingOverlay.tsx new file mode 100644 index 000000000..320576533 --- /dev/null +++ b/src/Common/LoadingOverlay.tsx @@ -0,0 +1,31 @@ +import { Overlay, Spinner, SpinnerSize } from "@fluentui/react"; +import React from "react"; + +interface LoadingOverlayProps { + isLoading: boolean; + label: string; +} + +const LoadingOverlay: React.FC = ({ isLoading, label }) => { + if (!isLoading) { + return null; + } + + return ( + + + + ); +}; + +export default LoadingOverlay; diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index 298b9268d..48eef3a48 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -17,6 +17,7 @@ const defaultHeaders = { [HttpHeaders.apiType]: ApiType.MongoDB.toString(), [CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100", [CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15", + [HttpHeaders.sessionId]: userContext.sessionId, }; function authHeaders() { diff --git a/src/Common/dataAccess/readCollectionOffer.ts b/src/Common/dataAccess/readCollectionOffer.ts index d3c8e25cd..6712f274b 100644 --- a/src/Common/dataAccess/readCollectionOffer.ts +++ b/src/Common/dataAccess/readCollectionOffer.ts @@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils"; import { readOfferWithSDK } from "./readOfferWithSDK"; export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise => { - const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`); - if (isFabric()) { // Not exposing offers in Fabric return undefined; } + const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`); + try { if ( userContext.authType === AuthType.AAD && diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 1ea0f2075..99ff76f9d 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -446,6 +446,7 @@ export interface DataExplorerInputsFrame { feedbackPolicies?: any; aadToken?: string; containerCopyEnabled?: boolean; + sessionId?: string; } export interface SelfServeFrameInputs { diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 6394359db..2208a6ac9 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -1,5 +1,7 @@ +import Explorer from "Explorer/Explorer"; import React from "react"; import { userContext } from "UserContext"; +import { logError } from "../../../Common/Logger"; import { useSidePanel } from "../../../hooks/useSidePanel"; import { cancel, @@ -28,12 +30,12 @@ import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails"; import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes"; -export const openCreateCopyJobPanel = () => { +export const openCreateCopyJobPanel = (explorer: Explorer) => { const sidePanelState = useSidePanel.getState(); sidePanelState.setPanelHasConsole(false); sidePanelState.openSidePanel( ContainerCopyMessages.createCopyJobPanelTitle, - , + , "650px", ); }; @@ -159,7 +161,8 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: onSuccess(); return response; } catch (error) { - console.error("Error submitting create copy job:", error); + const errorMessage = error.message || "Error submitting create copy job. Please try again later."; + logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob"); throw error; } }; @@ -198,8 +201,7 @@ export const updateCopyJobStatus = async (job: CopyJobType, action: string): Pro pattern, `'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`, ); - - console.error(`Error updating copy job status: ${normalizedErrorMessage}`); + logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus"); throw error; } }; diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx index f7d981484..9f163613d 100644 --- a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -13,8 +13,8 @@ const rootStyle = { }, }; -const CopyJobCommandBar: React.FC = ({ container }) => { - const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container); +const CopyJobCommandBar: React.FC = ({ explorer }) => { + const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer); const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor); return ( diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index a4d4b9d8b..a1472793b 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -9,7 +9,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages"; import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes"; -function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] { +function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] { const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref); const buttons: CopyJobCommandBarBtnType[] = [ { @@ -17,7 +17,7 @@ function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] { iconSrc: AddIcon, label: ContainerCopyMessages.createCopyJobButtonLabel, ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel, - onClick: Actions.openCreateCopyJobPanel, + onClick: Actions.openCreateCopyJobPanel.bind(null, explorer), }, { key: "refresh", @@ -34,7 +34,7 @@ function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] { label: ContainerCopyMessages.feedbackButtonLabel, ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel, onClick: () => { - container.openContainerCopyFeedbackBlade(); + explorer.openContainerCopyFeedbackBlade(); }, }); } @@ -54,6 +54,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp }; } -export function getCommandBarButtons(container: Explorer): CommandButtonComponentProps[] { - return getCopyJobBtns(container).map(btnMapper); +export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] { + return getCopyJobBtns(explorer).map(btnMapper); } diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 80c9c658f..526b6ffab 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -36,6 +36,9 @@ export default { databaseDropdownPlaceholder: "Select a database", containerDropdownLabel: "Container", containerDropdownPlaceholder: "Select a container", + createNewContainerSubHeading: "Select the properties for your container.", + createContainerButtonLabel: "Create a new container", + createContainerHeading: "Create new container", // Preview and Create Screen jobNameLabel: "Job name", @@ -48,13 +51,24 @@ export default { // Assign Permissions Screen assignPermissions: { - description: + crossAccountDescription: "To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.", + intraAccountOnlineDescription: (accountName: string) => + `Follow the steps below to enable online copy on your "${accountName}" account.`, + commonConfiguration: { + title: "Common configuration", + description: "Basic permissions required for copy operations", + }, + onlineConfiguration: { + title: "Online copy configuration", + description: "Additional permissions required for online copy operations", + }, }, toggleBtn: { onText: "On", offText: "Off", }, + popoverOverlaySpinnerLabel: "Please wait while we process your request...", addManagedIdentity: { title: "System-assigned managed identity enabled.", description: @@ -115,7 +129,7 @@ export default { }, onlineCopyEnabled: { title: "Online copy enabled", - description: (accountName: string) => `Use Azure CLI to enable Online copy on "${accountName}".`, + description: (accountName: string) => `Enable Online copy on "${accountName}".`, hrefText: "Learn more about online copy jobs", href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy", buttonText: "Enable Online Copy", diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx index 026fb7f09..2d7cccb87 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx @@ -5,7 +5,7 @@ import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefStat import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs"; import { ContainerCopyProps } from "./Types/CopyJobTypes"; -const ContainerCopyPanel: React.FC = ({ container }) => { +const ContainerCopyPanel: React.FC = ({ explorer }) => { const monitorCopyJobsRef = React.useRef(); useEffect(() => { if (monitorCopyJobsRef.current) { @@ -14,8 +14,8 @@ const ContainerCopyPanel: React.FC = ({ container }) => { }, [monitorCopyJobsRef.current]); return (
- - + +
); }; diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index 16f17598b..dab4bd3c0 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -1,3 +1,5 @@ +import Explorer from "Explorer/Explorer"; +import { Subscription } from "Contracts/DataModels"; import React from "react"; import { userContext } from "UserContext"; import { CopyJobMigrationType } from "../Enums/CopyJobEnums"; @@ -14,6 +16,7 @@ export const useCopyJobContext = (): CopyJobContextProviderType => { interface CopyJobContextProviderProps { children: React.ReactNode; + explorer: Explorer; } const getInitialCopyJobState = (): CopyJobContextState => { @@ -21,8 +24,10 @@ const getInitialCopyJobState = (): CopyJobContextState => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null, - account: null, + subscription: { + subscriptionId: userContext.subscriptionId || "", + } as Subscription, + account: userContext.databaseAccount || null, databaseId: "", containerId: "", }, @@ -39,16 +44,24 @@ const getInitialCopyJobState = (): CopyJobContextState => { const CopyJobContextProvider: React.FC = (props) => { const [copyJobState, setCopyJobState] = React.useState(getInitialCopyJobState()); const [flow, setFlow] = React.useState(null); + const [contextError, setContextError] = React.useState(null); const resetCopyJobState = () => { setCopyJobState(getInitialCopyJobState()); }; - return ( - - {props.children} - - ); + const contextValue: CopyJobContextProviderType = { + contextError, + setContextError, + copyJobState, + setCopyJobState, + flow, + setFlow, + resetCopyJobState, + explorer: props.explorer, + }; + + return {props.children}; }; export default CopyJobContextProvider; diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts index a37fa660a..75cc4acd2 100644 --- a/src/Explorer/ContainerCopy/CopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -1,5 +1,5 @@ import { DatabaseAccount } from "Contracts/DataModels"; -import { CopyJobErrorType } from "./Types/CopyJobTypes"; +import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes"; const azurePortalMpacEndpoint = "https://ms.portal.azure.com/"; @@ -106,7 +106,7 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) { return null; } const pattern = new RegExp( - "/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB/databaseAccounts/([^/]+)", + "/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)", "i", ); const matches = accountId.match(pattern); @@ -114,3 +114,58 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) { const [_, subscriptionId, resourceGroup, accountName] = matches || []; return { subscriptionId, resourceGroup, accountName }; } + +export function getContainerIdentifiers(container: CopyJobContextState["source"] | CopyJobContextState["target"]) { + return { + accountId: container?.account?.id || "", + databaseId: container?.databaseId || "", + containerId: container?.containerId || "", + }; +} + +export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean { + const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId); + const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId); + return ( + sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId && + sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup && + sourceAccountDetails?.accountName === targetAccountDetails?.accountName + ); +} + +export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean { + if (prevJobs.length !== newJobs.length) { + return false; + } + return prevJobs.every((prevJob: CopyJobType) => { + const newJob = newJobs.find((job) => job.Name === prevJob.Name); + if (!newJob) { + return false; + } + return prevJob.Status === newJob.Status; + }); +} + +const truncateLength = 5; +const truncateName = (name: string, length: number = truncateLength): string => { + return name.length <= length ? name : name.slice(0, length); +}; + +export function getDefaultJobName( + selectedDatabaseAndContainers: { + sourceDatabaseName?: string; + sourceContainerName?: string; + targetDatabaseName?: string; + targetContainerName?: string; + }[], +): string { + if (selectedDatabaseAndContainers.length === 1) { + const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } = + selectedDatabaseAndContainers[0]; + const timestamp = new Date().getTime().toString(); + const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`; + const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`; + return `${sourcePart}_${targetPart}_${timestamp}`; + } + return ""; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx index 4c3f5e98d..160c6d973 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx @@ -1,5 +1,6 @@ import { Link, Stack, Text, Toggle } from "@fluentui/react"; import React, { useCallback } from "react"; +import { logError } from "../../../../../Common/Logger"; import { assignRole } from "../../../../../Utils/arm/RbacUtils"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; @@ -21,7 +22,7 @@ type AddReadPermissionToDefaultIdentityProps = Partial; const AddReadPermissionToDefaultIdentity: React.FC = () => { const [loading, setLoading] = React.useState(false); - const { copyJobState, setCopyJobState } = useCopyJobContext(); + const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext(); const [readPermissionAssigned, onToggle] = useToggle(false); const handleAddReadPermission = useCallback(async () => { @@ -48,11 +49,14 @@ const AddReadPermissionToDefaultIdentity: React.FC diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx index 6a5e69154..7b1f96241 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx @@ -6,8 +6,10 @@ import WarningIcon from "../../../../../../images/warning.svg"; import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { isIntraAccountCopy } from "../../../CopyJobUtils"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; -import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection"; +import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache"; +import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection"; const PermissionSection: React.FC = ({ id, title, Component, completed, disabled }) => ( @@ -29,35 +31,91 @@ const PermissionSection: React.FC = ({ id, title, Compo ); -const AssignPermissions = () => { - const { copyJobState } = useCopyJobContext(); - const permissionSections = usePermissionSections(copyJobState); +const PermissionGroup: React.FC = ({ title, description, sections }) => { const [openItems, setOpenItems] = React.useState([]); - const indentLevels = React.useMemo( - () => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }), - [], - ); - useEffect(() => { - const firstIncompleteSection = permissionSections.find((section) => !section.completed); + const firstIncompleteSection = sections.find((section) => !section.completed); const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : []; if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) { setOpenItems(nextOpenItems); } - }, [permissionSections]); + }, [sections]); return ( - - {ContainerCopyMessages.assignPermissions.description} - {permissionSections?.length === 0 ? ( + + + + {title} + + {description && ( + + {description} + + )} + + + + {sections.map((section) => ( + + ))} + + + ); +}; + +const AssignPermissions = () => { + const { setValidationCache } = useCopyJobPrerequisitesCache(); + const { copyJobState } = useCopyJobContext(); + const permissionGroups = usePermissionSections(copyJobState); + + const totalSectionsCount = React.useMemo( + () => permissionGroups.reduce((total, group) => total + group.sections.length, 0), + [permissionGroups], + ); + + const indentLevels = React.useMemo( + () => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }), + [copyJobState.migrationType], + ); + + const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id); + + useEffect(() => { + return () => { + setValidationCache(new Map()); + }; + }, []); + + return ( + + + {isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online + ? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription( + copyJobState?.source?.account?.name || "", + ) + : ContainerCopyMessages.assignPermissions.crossAccountDescription} + + + {totalSectionsCount === 0 ? ( ) : ( - - {permissionSections.map((section) => ( - + + {permissionGroups.map((group) => ( + ))} - + )} ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx index f616df1c6..1c1d6bfd5 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -2,6 +2,10 @@ import { Link, PrimaryButton, Stack } from "@fluentui/react"; import { DatabaseAccount } from "Contracts/DataModels"; import React from "react"; import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import { CapabilityNames } from "../../../../../Common/Constants"; +import LoadingOverlay from "../../../../../Common/LoadingOverlay"; +import { logError } from "../../../../../Common/Logger"; +import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; @@ -19,8 +23,10 @@ const OnlineCopyEnabled: React.FC = () => { const [showRefreshButton, setShowRefreshButton] = React.useState(false); const intervalRef = React.useRef(null); const timeoutRef = React.useRef(null); - const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); + const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); const selectedSourceAccount = source?.account; + const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? []; + const { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, @@ -38,16 +44,24 @@ const OnlineCopyEnabled: React.FC = () => { setLoading(false); } } catch (error) { - console.error("Error fetching source account after enabling online copy:", error); - setLoading(false); + const errorMessage = + error.message || "Error fetching source account after enabling online copy. Please try again later."; + logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount"); + setContextError(errorMessage); + clearAccountFetchInterval(); } }; - const clearIntervalAndShowRefresh = () => { + const clearAccountFetchInterval = () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + setLoading(false); + }; + + const clearIntervalAndShowRefresh = () => { + clearAccountFetchInterval(); setShowRefreshButton(true); }; @@ -56,18 +70,42 @@ const OnlineCopyEnabled: React.FC = () => { handleFetchAccount(); }; + const handleOnlineCopyEnable = async () => { + setLoading(true); + setShowRefreshButton(false); + + try { + await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { + properties: { + enableAllVersionsAndDeletesChangeFeed: true, + }, + }); + + await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { + properties: { + capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], + }, + }); + + intervalRef.current = setInterval(() => { + handleFetchAccount(); + }, 30 * 1000); + + timeoutRef.current = setTimeout( + () => { + clearIntervalAndShowRefresh(); + }, + 10 * 60 * 1000, + ); + } catch (error) { + const errorMessage = error.message || "Failed to enable online copy feature. Please try again later."; + logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable"); + setContextError(errorMessage); + setLoading(false); + } + }; + React.useEffect(() => { - intervalRef.current = setInterval(() => { - handleFetchAccount(); - }, 30 * 1000); - - timeoutRef.current = setTimeout( - () => { - clearIntervalAndShowRefresh(); - }, - 15 * 60 * 1000, - ); - return () => { if (intervalRef.current) { clearInterval(intervalRef.current); @@ -82,6 +120,7 @@ const OnlineCopyEnabled: React.FC = () => { return ( + {ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}  @@ -89,32 +128,7 @@ const OnlineCopyEnabled: React.FC = () => { -
-          
-            {`# Set shell variables
-$resourceGroupName = 
-$accountName = 
-$EnableOnlineContainerCopy = "EnableOnlineContainerCopy"
-
-# List down existing capabilities of your account
-$cosmosdb = az cosmosdb show --resource-group $resourceGroupName --name $accountName
-
-$capabilities = (($cosmosdb | ConvertFrom-Json).capabilities)
-
-# Append EnableOnlineContainerCopy capability in the list of capabilities
-$capabilitiesToAdd = @()
-foreach ($item in $capabilities) {
-  $capabilitiesToAdd += $item.name
-}
-$capabilitiesToAdd += $EnableOnlineContainerCopy
-
-# Update Cosmos DB account
-az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourceGroupName`}
-          
-        
-
- {showRefreshButton && ( - + {showRefreshButton ? ( - - )} + ) : ( + + )} +
); }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index eb072d92b..f62331677 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -2,6 +2,8 @@ import { Link, PrimaryButton, Stack, Text } from "@fluentui/react"; import { DatabaseAccount } from "Contracts/DataModels"; import React, { useEffect, useRef, useState } from "react"; import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import LoadingOverlay from "../../../../../Common/LoadingOverlay"; +import { logError } from "../../../../../Common/Logger"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; @@ -63,23 +65,30 @@ const PointInTimeRestore: React.FC = () => { setLoading(false); } } catch (error) { - console.error("Error fetching source account after Point-in-Time Restore:", error); - setLoading(false); + const errorMessage = + error.message || "Error fetching source account after Point-in-Time Restore. Please try again later."; + logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount"); + clearAccountFetchInterval(); } }; - const clearIntervalAndShowRefresh = () => { + const clearAccountFetchInterval = () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } setLoading(false); + }; + + const clearIntervalAndShowRefresh = () => { + clearAccountFetchInterval(); setShowRefreshButton(true); }; - const handleRefresh = () => { + const handleRefresh = async () => { setLoading(true); - handleFetchAccount(); + await handleFetchAccount(); + setLoading(false); }; const openWindowAndMonitor = () => { @@ -95,12 +104,13 @@ const PointInTimeRestore: React.FC = () => { () => { clearIntervalAndShowRefresh(); }, - 15 * 60 * 1000, + 10 * 60 * 1000, ); }; return ( + {ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")} {tooltipContent && ( diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx index 08c79da3b..9ac826e8c 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx @@ -1,5 +1,6 @@ import { DatabaseAccount } from "Contracts/DataModels"; import { useCallback, useState } from "react"; +import { logError } from "../../../../../../Common/Logger"; import { useCopyJobContext } from "../../../../Context/CopyJobContext"; import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils"; @@ -19,7 +20,7 @@ interface UseManagedIdentityUpdaterReturn { const useManagedIdentity = ( updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"], ): UseManagedIdentityUpdaterReturn => { - const { copyJobState, setCopyJobState } = useCopyJobContext(); + const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext(); const [loading, setLoading] = useState(false); const handleAddSystemIdentity = useCallback(async (): Promise => { @@ -40,7 +41,9 @@ const useManagedIdentity = ( })); } } catch (error) { - console.error("Error enabling system-assigned managed identity:", error); + const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later."; + logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity"); + setContextError(errorMessage); } finally { setLoading(false); } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx index 53db66ff8..8ee6d8355 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { CapabilityNames } from "../../../../../../Common/Constants"; import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; -import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils"; +import { getAccountDetailsFromResourceId, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils"; import { BackupPolicyType, CopyJobMigrationType, @@ -26,6 +26,13 @@ export interface PermissionSectionConfig { validate?: (state: CopyJobContextState) => boolean | Promise; } +export interface PermissionGroupConfig { + id: string; + title: string; + description: string; + sections: PermissionSectionConfig[]; +} + export const SECTION_IDS = { addManagedIdentity: "addManagedIdentity", defaultManagedIdentity: "defaultManagedIdentity", @@ -127,24 +134,81 @@ export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinition } /** - * Returns the permission sections configuration for the Assign Permissions screen. - * Memoizes derived values for performance and decouples logic for testability. + * Validates sections within a group sequentially. */ -const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => { - const sourceAccountId = state?.source?.account?.id || ""; - const targetAccountId = state?.target?.account?.id || ""; +const validateSectionsInGroup = async ( + sections: PermissionSectionConfig[], + state: CopyJobContextState, + validationCache: Map, +): Promise => { + const result: PermissionSectionConfig[] = []; + + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + + if (validationCache.has(section.id) && validationCache.get(section.id) === true) { + result.push({ ...section, completed: true }); + continue; + } + + if (section.validate) { + const isValid = await section.validate(state); + validationCache.set(section.id, isValid); + result.push({ ...section, completed: isValid }); + + if (!isValid) { + // Mark remaining sections in this group as incomplete + for (let j = i + 1; j < sections.length; j++) { + result.push({ ...sections[j], completed: false }); + } + break; + } + } else { + validationCache.set(section.id, false); + result.push({ ...section, completed: false }); + } + } + + return result; +}; + +/** + * Returns the permission groups configuration for the Assign Permissions screen. + * Groups validate independently but sections within each group validate sequentially. + */ +const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => { + const sourceAccount = getContainerIdentifiers(state.source); + const targetAccount = getContainerIdentifiers(state.target); const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache(); - const [permissionSections, setPermissionSections] = useState(null); + const [permissionGroups, setPermissionGroups] = useState(null); const isValidatingRef = useRef(false); - const sectionToValidate = useMemo(() => { - const baseSections = sourceAccountId === targetAccountId ? [] : [...PERMISSION_SECTIONS_CONFIG]; - if (state.migrationType === CopyJobMigrationType.Online) { - return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS]; + const groupsToValidate = useMemo(() => { + const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId); + const commonSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG]; + const groups: PermissionGroupConfig[] = []; + + if (commonSections.length > 0) { + groups.push({ + id: "commonConfigs", + title: ContainerCopyMessages.assignPermissions.commonConfiguration.title, + description: ContainerCopyMessages.assignPermissions.commonConfiguration.description, + sections: commonSections, + }); } - return baseSections; - }, [sourceAccountId, targetAccountId, state.migrationType]); + + if (state.migrationType === CopyJobMigrationType.Online) { + groups.push({ + id: "onlineConfigs", + title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title, + description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description, + sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS], + }); + } + + return groups; + }, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]); const memoizedValidationCache = useMemo(() => { if (state.migrationType === CopyJobMigrationType.Offline) { @@ -155,52 +219,39 @@ const usePermissionSections = (state: CopyJobContextState): PermissionSectionCon }, [state.migrationType]); useEffect(() => { - const validateSections = async () => { + const validateGroups = async () => { if (isValidatingRef.current) { return; } isValidatingRef.current = true; - const result: PermissionSectionConfig[] = []; const newValidationCache = new Map(memoizedValidationCache); - for (let i = 0; i < sectionToValidate.length; i++) { - const section = sectionToValidate[i]; + // Validate all groups independently (in parallel) + const validatedGroups = await Promise.all( + groupsToValidate.map(async (group) => { + const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache); - if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) { - result.push({ ...section, completed: true }); - continue; - } - if (section.validate) { - const isValid = await section.validate(state); - newValidationCache.set(section.id, isValid); - result.push({ ...section, completed: isValid }); - - if (!isValid) { - for (let j = i + 1; j < sectionToValidate.length; j++) { - result.push({ ...sectionToValidate[j], completed: false }); - } - break; - } - } else { - newValidationCache.set(section.id, false); - result.push({ ...section, completed: false }); - } - } + return { + ...group, + sections: validatedSections, + }; + }), + ); setValidationCache(newValidationCache); - setPermissionSections(result); + setPermissionGroups(validatedGroups); isValidatingRef.current = false; }; - validateSections(); + validateGroups(); return () => { isValidatingRef.current = false; }; - }, [state, sectionToValidate]); + }, [state, groupsToValidate]); - return permissionSections ?? []; + return permissionGroups ?? []; }; export default usePermissionSections; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx index eec4f5402..5a76d66eb 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx @@ -2,6 +2,8 @@ /* eslint-disable react/display-name */ import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react"; import React from "react"; +import LoadingOverlay from "../../../../../Common/LoadingOverlay"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; interface PopoverContainerProps { isLoading?: boolean; @@ -19,17 +21,13 @@ const PopoverContainer: React.FC = React.memo( tokens={{ childrenGap: 20 }} style={{ maxWidth: 450 }} > + {title} {children} - + diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx new file mode 100644 index 000000000..01a2db73f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx @@ -0,0 +1,53 @@ +import { Stack, Text } from "@fluentui/react"; +import Explorer from "Explorer/Explorer"; +import { useSidePanel } from "hooks/useSidePanel"; +import { produce } from "immer"; +import React, { useCallback, useEffect } from "react"; +import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; + +type AddCollectionPanelWrapperProps = { + explorer?: Explorer; + goBack?: () => void; +}; + +const AddCollectionPanelWrapper: React.FunctionComponent = ({ explorer, goBack }) => { + const { setCopyJobState } = useCopyJobContext(); + + useEffect(() => { + const sidePanelStore = useSidePanel.getState(); + if (sidePanelStore.headerText !== ContainerCopyMessages.createContainerHeading) { + sidePanelStore.setHeaderText(ContainerCopyMessages.createContainerHeading); + } + return () => { + sidePanelStore.setHeaderText(ContainerCopyMessages.createCopyJobPanelTitle); + }; + }, []); + + const handleAddCollectionSuccess = useCallback( + (collectionData: { databaseId: string; collectionId: string }) => { + setCopyJobState( + produce((state) => { + state.target.databaseId = collectionData.databaseId; + state.target.containerId = collectionData.collectionId; + }), + ); + goBack?.(); + }, + [goBack], + ); + + return ( + + + {ContainerCopyMessages.createNewContainerSubHeading} + + + + + + ); +}; + +export default AddCollectionPanelWrapper; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx index cd7f39bf1..55368cbf6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx @@ -1,5 +1,6 @@ import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; import React from "react"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation"; import NavigationControls from "./Components/NavigationControls"; @@ -12,27 +13,27 @@ const CreateCopyJobScreens: React.FC = () => { handlePrevious, handleCancel, primaryBtnText, - error, - setError, + showAddCollectionPanel, } = useCopyJobNavigation(); + const { contextError, setContextError } = useCopyJobContext(); return ( - {error && ( + {contextError && ( setError(null)} + onDismiss={() => setContextError(null)} dismissButtonAriaLabel="Close" truncated={true} overflowButtonAriaLabel="See more" > - {error} + {contextError} )} - {currentScreen?.component} + {React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })} { +const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => { return ( - + ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx index c270ccdf5..050d696e9 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx @@ -1,8 +1,9 @@ import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react"; -import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow"; -import React from "react"; +import React, { useEffect } from "react"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { getDefaultJobName } from "../../../CopyJobUtils"; +import FieldRow from "../Components/FieldRow"; import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils"; const PreviewCopyJob: React.FC = () => { @@ -16,6 +17,11 @@ const PreviewCopyJob: React.FC = () => { targetContainerName: copyJobState.target?.containerId, }, ]; + + useEffect(() => { + onJobNameChange(undefined, getDefaultJobName(selectedDatabaseAndContainers)); + }, []); + const jobName = copyJobState.jobName; const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx index 423920b43..b24aed7b3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -27,4 +27,5 @@ export const AccountDropdown: React.FC = React.memo( /> ), + (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey, ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx index 67b8e4f87..2627918a6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx @@ -25,4 +25,5 @@ export const SubscriptionDropdown: React.FC = React.m /> ), + (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey, ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index 2932e6229..17f323413 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -16,6 +16,7 @@ import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils const SelectAccount = React.memo(() => { const { copyJobState, setCopyJobState } = useCopyJobContext(); const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + const selectedSourceAccountId = copyJobState?.source?.account?.id; const subscriptions: Subscription[] = useSubscriptions(); const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId); @@ -38,7 +39,7 @@ const SelectAccount = React.memo(() => { handleSelectSourceAccount("account", option?.data)} /> diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx index 16d17c33b..adb36b3a1 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx @@ -2,6 +2,7 @@ import React from "react"; import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels"; import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache"; export function useDropdownOptions( subscriptions: Subscription[], @@ -36,6 +37,7 @@ export function useDropdownOptions( type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"]; export function useEventHandlers(setCopyJobState: setCopyJobStateType) { + const { setValidationCache } = useCopyJobPrerequisitesCache(); const handleSelectSourceAccount = React.useCallback( (type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => { setCopyJobState((prevState: CopyJobContextState) => { @@ -60,8 +62,9 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) { } return prevState; }); + setValidationCache(new Map()); }, - [setCopyJobState], + [setCopyJobState, setValidationCache], ); const handleMigrationTypeChange = React.useCallback( @@ -70,8 +73,9 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) { ...prevState, migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, })); + setValidationCache(new Map()); }, - [setCopyJobState], + [setCopyJobState, setValidationCache], ); return { handleSelectSourceAccount, handleMigrationTypeChange }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx index fb42c3a69..8bfc76167 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx @@ -9,7 +9,11 @@ import { DatabaseContainerSection } from "./components/DatabaseContainerSection" import { dropDownChangeHandler } from "./Events/DropDownChangeHandler"; import { useMemoizedSourceAndTargetData } from "./memoizedData"; -const SelectSourceAndTargetContainers = () => { +type SelectSourceAndTargetContainers = { + showAddCollectionPanel?: () => void; +}; + +const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => { const { copyJobState, setCopyJobState } = useCopyJobContext(); const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } = useMemoizedSourceAndTargetData(copyJobState); @@ -62,6 +66,7 @@ const SelectSourceAndTargetContainers = () => { selectedContainer={target?.containerId} containerDisabled={!target?.databaseId} containerOnChange={onDropdownChange("targetContainer")} + handleOnDemandCreateContainer={showAddCollectionPanel} /> ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx index 1ecfaa4e9..2edac6ce8 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx @@ -1,4 +1,4 @@ -import { Dropdown, Stack } from "@fluentui/react"; +import { ActionButton, Dropdown, Stack } from "@fluentui/react"; import React from "react"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes"; @@ -14,6 +14,7 @@ export const DatabaseContainerSection = ({ selectedContainer, containerDisabled, containerOnChange, + handleOnDemandCreateContainer, }: DatabaseContainerSectionProps) => ( @@ -29,15 +30,22 @@ export const DatabaseContainerSection = ({ /> - + + + {handleOnDemandCreateContainer && ( + handleOnDemandCreateContainer()}> + {ContainerCopyMessages.createContainerButtonLabel} + + )} + ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index 4bd552455..dd8059547 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useReducer, useState } from "react"; import { useSidePanel } from "../../../../hooks/useSidePanel"; import { submitCreateCopyJob } from "../../Actions/CopyJobActions"; import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils"; import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; @@ -33,12 +34,15 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt export function useCopyJobNavigation() { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { copyJobState, resetCopyJobState } = useCopyJobContext(); - const screens = useCreateCopyJobScreensList(); + const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext(); const { validationCache: cache } = useCopyJobPrerequisitesCache(); const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] }); + const handlePrevious = useCallback(() => { + dispatch({ type: "PREVIOUS" }); + }, [dispatch]); + + const screens = useCreateCopyJobScreensList(handlePrevious); const currentScreenKey = state.screenHistory[state.screenHistory.length - 1]; const currentScreen = screens.find((screen) => screen.key === currentScreenKey); @@ -51,7 +55,9 @@ export function useCopyJobNavigation() { }, [currentScreen.key, copyJobState, cache, isLoading]); const primaryBtnText = useMemo(() => { - if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { + if (currentScreenKey === SCREEN_KEYS.CreateCollection) { + return "Create"; + } else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { return "Copy"; } return "Next"; @@ -65,24 +71,13 @@ export function useCopyJobNavigation() { useSidePanel.getState().closeSidePanel(); }, []); - const getContainerIdentifiers = (container: typeof copyJobState.source | typeof copyJobState.target) => ({ - accountId: container?.account?.id || "", - databaseId: container?.databaseId || "", - containerId: container?.containerId || "", - }); - - const isSameAccount = ( - sourceIds: ReturnType, - targetIds: ReturnType, - ) => sourceIds.accountId === targetIds.accountId; - const areContainersIdentical = () => { const { source, target } = copyJobState; const sourceIds = getContainerIdentifiers(source); const targetIds = getContainerIdentifiers(target); return ( - isSameAccount(sourceIds, targetIds) && + isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) && sourceIds.databaseId === targetIds.databaseId && sourceIds.containerId === targetIds.containerId ); @@ -90,9 +85,10 @@ export function useCopyJobNavigation() { const shouldNotShowPermissionScreen = () => { const { source, target, migrationType } = copyJobState; + const sourceIds = getContainerIdentifiers(source); + const targetIds = getContainerIdentifiers(target); return ( - migrationType === CopyJobMigrationType.Offline && - isSameAccount(getContainerIdentifiers(source), getContainerIdentifiers(target)) + migrationType === CopyJobMigrationType.Offline && isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) ); }; @@ -105,19 +101,40 @@ export function useCopyJobNavigation() { error instanceof Error ? error.message || "Failed to create copy job. Please try again later." : "Failed to create copy job. Please try again later."; - setError(errorMessage); + setContextError(errorMessage); } finally { setIsLoading(false); } }; + const handleAddCollectionPanelSubmit = () => { + const form = document.getElementById("panelContainer") as HTMLFormElement; + if (form) { + const submitEvent = new Event("submit", { + bubbles: true, + cancelable: true, + }); + form.dispatchEvent(submitEvent); + } + }; + + const showAddCollectionPanel = useCallback(() => { + dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.CreateCollection }); + }, [dispatch]); + const handlePrimary = useCallback(() => { + if (currentScreenKey === SCREEN_KEYS.CreateCollection) { + handleAddCollectionPanelSubmit(); + return; + } if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) { - setError("Source and destination containers cannot be the same. Please select different containers to proceed."); + setContextError( + "Source and destination containers cannot be the same. Please select different containers to proceed.", + ); return; } - setError(null); + setContextError(null); const transitions = { [SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen() ? SCREEN_KEYS.SelectSourceAndTargetContainers @@ -134,10 +151,6 @@ export function useCopyJobNavigation() { } }, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]); - const handlePrevious = useCallback(() => { - dispatch({ type: "PREVIOUS" }); - }, []); - return { currentScreen, isPrimaryDisabled, @@ -145,8 +158,7 @@ export function useCopyJobNavigation() { handlePrimary, handlePrevious, handleCancel, + showAddCollectionPanel, primaryBtnText, - error, - setError, }; } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index 1b0c74f05..acb17f602 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -1,11 +1,14 @@ import React from "react"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; import { CopyJobContextState } from "../../Types/CopyJobTypes"; import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions"; +import AddCollectionPanelWrapper from "../Screens/CreateContainer/AddCollectionPanelWrapper"; import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob"; import SelectAccount from "../Screens/SelectAccount/SelectAccount"; import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers"; const SCREEN_KEYS = { + CreateCollection: "CreateCollection", SelectAccount: "SelectAccount", SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", PreviewCopyJob: "PreviewCopyJob", @@ -23,7 +26,9 @@ type Screen = { validations: Validation[]; }; -function useCreateCopyJobScreensList() { +function useCreateCopyJobScreensList(goBack: () => void): Screen[] { + const { explorer } = useCopyJobContext(); + return React.useMemo( () => [ { @@ -50,13 +55,18 @@ function useCreateCopyJobScreensList() { }, ], }, + { + key: SCREEN_KEYS.CreateCollection, + component: , + validations: [], + }, { key: SCREEN_KEYS.PreviewCopyJob, component: , validations: [ { validate: (state: CopyJobContextState) => - !!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)), + !!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-._]+$/.test(state?.jobName)), message: "Please enter a job name to proceed", }, ], @@ -80,7 +90,7 @@ function useCreateCopyJobScreensList() { ], }, ], - [], + [explorer], ); } diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx index 57bc99acd..b43307be9 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -11,7 +11,14 @@ interface CopyJobActionMenuProps { const CopyJobActionMenu: React.FC = ({ job, handleClick }) => { const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null); - if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) { + if ( + [ + CopyJobStatusType.Completed, + CopyJobStatusType.Cancelled, + CopyJobStatusType.Failed, + CopyJobStatusType.Faulted, + ].includes(job.Status) + ) { return null; } @@ -55,7 +62,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick [CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status) ) { const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume); - if (job.Mode === CopyJobMigrationType.Online) { + if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) { filteredItems.push({ key: CopyJobActions.complete, text: ContainerCopyMessages.MonitorJobs.Actions.complete, @@ -67,7 +74,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick return filteredItems; } - if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) { + if ([CopyJobStatusType.Skipped].includes(job.Status)) { return baseItems.filter((item) => item.key === CopyJobActions.resume); } diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx index d7f32d3e8..1b8ad0436 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx @@ -1,18 +1,24 @@ import { ActionButton, Image } from "@fluentui/react"; -import React, { useCallback } from "react"; +import Explorer from "Explorer/Explorer"; +import React from "react"; import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg"; import * as Actions from "../../Actions/CopyJobActions"; import ContainerCopyMessages from "../../ContainerCopyMessages"; -interface CopyJobsNotFoundProps {} +interface CopyJobsNotFoundProps { + explorer: Explorer; +} -const CopyJobsNotFound: React.FC = () => { - const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []); +const CopyJobsNotFound: React.FC = ({ explorer }) => { return (
{ContainerCopyMessages.noCopyJobsTitle}

{ContainerCopyMessages.noCopyJobsTitle}

- + {ContainerCopyMessages.createCopyJobButtonText}
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index cb4d0fea8..7278bc26c 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/display-name */ import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree"; +import Explorer from "Explorer/Explorer"; import React, { forwardRef, useEffect, useImperativeHandle } from "react"; import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions"; import { convertToCamelCase } from "../CopyJobUtils"; @@ -11,13 +12,15 @@ import CopyJobsList from "./Components/CopyJobsList"; const FETCH_INTERVAL_MS = 30 * 1000; -interface MonitorCopyJobsProps {} +interface MonitorCopyJobsProps { + explorer: Explorer; +} export interface MonitorCopyJobsRef { refreshJobList: () => void; } -const MonitorCopyJobs = forwardRef((_props, ref) => { +const MonitorCopyJobs = forwardRef(({ explorer }, ref) => { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [jobs, setJobs] = React.useState([]); @@ -96,15 +99,15 @@ const MonitorCopyJobs = forwardRef((_p [], ); - const memoizedJobsList = React.useMemo(() => { + const renderJobsList = () => { if (loading) { return null; } if (jobs.length > 0) { return ; } - return ; - }, [jobs, loading, handleActionClick]); + return ; + }; return ( @@ -114,7 +117,7 @@ const MonitorCopyJobs = forwardRef((_p {error} )} - {memoizedJobsList} + {renderJobsList()} ); }); diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts index 1c031bb33..e9ebbd0da 100644 --- a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts +++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts @@ -6,7 +6,7 @@ import Explorer from "../../Explorer"; import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums"; export interface ContainerCopyProps { - container: Explorer; + explorer: Explorer; } export type CopyJobCommandBarBtnType = { @@ -48,6 +48,7 @@ export interface DatabaseContainerSectionProps { selectedContainer: string; containerDisabled?: boolean; containerOnChange: (ev: React.FormEvent, option: DropdownOptionType) => void; + handleOnDemandCreateContainer?: () => void; } export interface CopyJobContextState { @@ -73,11 +74,14 @@ export interface CopyJobFlowType { } export interface CopyJobContextProviderType { + contextError: string | null; + setContextError: React.Dispatch>; flow: CopyJobFlowType; setFlow: React.Dispatch>; copyJobState: CopyJobContextState | null; setCopyJobState: React.Dispatch>; resetCopyJobState: () => void; + explorer?: Explorer; } export type CopyJobType = { diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 25ebb0311..05d9facec 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -19,6 +19,10 @@ .createCopyJobScreensContainer { height: 100%; padding: 1em 1.5em; + + .pointInTimeRestoreContainer, .onlineCopyContainer { + position: relative; + } label { padding: 0; @@ -59,6 +63,7 @@ } } .popover-container { + border-radius: 6px; button[disabled] { cursor: not-allowed; opacity: 0.8; @@ -66,7 +71,7 @@ } .foreground { z-index: 10; - background-color: white; + background-color: #f9f9f9; padding: 20px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); transform: translate(0%, -9%); @@ -75,6 +80,24 @@ .createCopyJobErrorMessageBar { margin-bottom: 2em; } + .create-container-link-btn { + padding: 0; + height: 25px; + color: @LinkColor; + + &:focus { + outline: none; + } + } + + /* Create collection panel */ + .panelFormWrapper .panelMainContent { + padding: 0; + } + + .createCopyJobScreensFooter { + margin-top: 50px; + } } .monitorCopyJobs { @@ -118,8 +141,9 @@ .jobNameLink { color: @LinkColor; - text-decoration: underline; - cursor: pointer; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } } } diff --git a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index 5ab433785..58d7037b6 100644 --- a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -65,6 +65,8 @@ export interface AddCollectionPanelProps { explorer: Explorer; databaseId?: string; isQuickstart?: boolean; + isCopyJobFlow?: boolean; + onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void; } export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = { @@ -975,7 +977,9 @@ export class AddCollectionPanel extends React.Component - + {!this.props.isCopyJobFlow && ( + + )} {this.state.isExecuting && (
@@ -1415,8 +1419,13 @@ export class AddCollectionPanel extends React.Component { + beforeEach(() => { + updateUserContext({ + sessionId: "1234-5678", + }); + }); + it("should render Default properly", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 2e89418d8..bbf4c1438 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -212,6 +212,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ const styles = useStyles(); const explorerVersion = configContext.gitSha; + const sessionId: string = userContext.sessionId; const isEmulator = configContext.platform === Platform.Emulator; const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const showRetrySettings = @@ -1227,6 +1228,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{explorerVersion}
+
+
+
Session ID
+
{sessionId}
+
+
); diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 022cc8647..ca0243ec7 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -649,6 +649,22 @@ exports[`Settings Pane should render Default properly 1`] = `
+
+
+
+ Session ID +
+
+ 1234-5678 +
+
+
`; @@ -958,6 +974,22 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
+
+
+
+ Session ID +
+
+ 1234-5678 +
+
+
`; diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts index ff2d9a5ea..631e19e88 100644 --- a/src/Explorer/Tables/TableDataClient.ts +++ b/src/Explorer/Tables/TableDataClient.ts @@ -286,7 +286,7 @@ export class CassandraAPIDataClient extends TableDataClient { query, paginationToken, }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }); shouldNotify && @@ -440,7 +440,7 @@ export class CassandraAPIDataClient extends TableDataClient { keyspaceId: collection.databaseId, tableId: collection.id(), }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }) .then( @@ -482,7 +482,7 @@ export class CassandraAPIDataClient extends TableDataClient { keyspaceId: collection.databaseId, tableId: collection.id(), }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }) .then( @@ -518,7 +518,7 @@ export class CassandraAPIDataClient extends TableDataClient { resourceId: resourceId, query: query, }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }).then( (data: any) => { @@ -547,7 +547,7 @@ export class CassandraAPIDataClient extends TableDataClient { return cassandraEndpoint; } - private setAuthorizationHeader: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => { + private setCommonHeaders: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => { const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader(); xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token); @@ -555,6 +555,7 @@ export class CassandraAPIDataClient extends TableDataClient { xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken); } + xhr.setRequestHeader(Constants.HttpHeaders.sessionId, userContext.sessionId); return true; }; diff --git a/src/Explorer/Tabs/TabsBase.ts b/src/Explorer/Tabs/TabsBase.ts index 2b97fed3e..2602b672d 100644 --- a/src/Explorer/Tabs/TabsBase.ts +++ b/src/Explorer/Tabs/TabsBase.ts @@ -1,5 +1,6 @@ import { OpenTab } from "Contracts/ActionContracts"; import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts"; +import { substringUtf } from "Utils/StringUtils"; import * as ko from "knockout"; import * as Constants from "../../Common/Constants"; import * as ThemeUtility from "../../Common/ThemeUtility"; @@ -154,13 +155,13 @@ export default class TabsBase extends WaitsForTemplateViewModel { const db = this.database?.id(); if (coll) { if (coll.length > 8) { - return coll.slice(0, 5) + "…" + options.title; + return substringUtf(coll, 0, 5) + "…" + options.title; } else { return coll + "." + options.title; } } else if (db) { if (db.length > 8) { - return db.slice(0, 5) + "…" + options.title; + return substringUtf(db, 0, 5) + "…" + options.title; } else { return db + "." + options.title; } diff --git a/src/Main.tsx b/src/Main.tsx index e223f58f7..7b1a9690c 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -87,7 +87,7 @@ const App: React.FunctionComponent = () => {
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? ( - + ) : ( )} diff --git a/src/UserContext.ts b/src/UserContext.ts index ecc4f5807..a495b9971 100644 --- a/src/UserContext.ts +++ b/src/UserContext.ts @@ -4,6 +4,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants"; import { traceOpen } from "Shared/Telemetry/TelemetryProcessor"; import { useCarousel } from "hooks/useCarousel"; import { usePostgres } from "hooks/usePostgres"; +import { v4 as uuidv4 } from "uuid"; import { AuthType } from "./AuthType"; import { DatabaseAccount } from "./Contracts/DataModels"; import { SubscriptionType } from "./Contracts/SubscriptionType"; @@ -118,6 +119,7 @@ export interface UserContext { readonly dataPlaneRbacEnabled?: boolean; readonly refreshCosmosClient?: boolean; throughputBucketsEnabled?: boolean; + readonly sessionId: string; } export type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra" | "Postgres" | "VCoreMongo"; @@ -135,6 +137,7 @@ const userContext: UserContext = { features, subscriptionType: CollectionCreation.DefaultSubscriptionType, collectionCreationDefaults: CollectionCreationDefaults, + sessionId: uuidv4(), // Default sessionId - will be overwritten if provided by host }; export function isAccountNewerThanThresholdInMs(createdAt: string, threshold: number) { diff --git a/src/Utils/StringUtils.test.ts b/src/Utils/StringUtils.test.ts index bd626f492..408556f07 100644 --- a/src/Utils/StringUtils.test.ts +++ b/src/Utils/StringUtils.test.ts @@ -26,5 +26,22 @@ describe("StringUtils", () => { const transformedString: string | undefined = StringUtils.stripSpacesFromString(""); expect(transformedString).toBe(""); }); + + it("should return the right number of characters regardless of bytes used per character", () => { + // Tried to use a sample of characters across the range for each of the individual byte lengths + const ascii = "!,n~!,n~!,n~"; + const twoByteCharacters = "Āā߿܀Āā߿܀Āā߿܀"; + const threeByteCharacters = "ࠀ倀ꀀࠀ倀ꀀࠀ倀ꀀ"; + const fourByteCharacters = "𐀀𐔀𐨀𐿶𐀀𐔀𐨀𐿶𐀀𐔀𐨀𐿶"; + // Used a random character generator for each of the different byte-lengths of characters for the mixed tests + const mixedByteSizes = "Yח䙶𫶾eԚ疿𱺿]߉ꗫ𢆤*ɉ貸𪡑"; + + expect(StringUtils.substringUtf(ascii, 0, 5)).toBe("!,n~!"); + expect(StringUtils.substringUtf(twoByteCharacters, 0, 5)).toBe("Āā߿܀Ā"); + expect(StringUtils.substringUtf(threeByteCharacters, 0, 5)).toBe("ࠀ倀ꀀࠀ"); + expect(StringUtils.substringUtf(fourByteCharacters, 0, 5)).toBe("𐀀𐔀𐨀𐿶𐀀"); + expect(StringUtils.substringUtf(mixedByteSizes, 0, 5)).toBe("Yח䙶𫶾e"); + expect(StringUtils.substringUtf(mixedByteSizes, 4, 4)).toBe("eԚ疿𱺿"); + }); }); }); diff --git a/src/Utils/StringUtils.ts b/src/Utils/StringUtils.ts index 02ceba5f2..3e0d68ba2 100644 --- a/src/Utils/StringUtils.ts +++ b/src/Utils/StringUtils.ts @@ -17,3 +17,58 @@ export function endsWith(stringToTest: string, suffix: string): boolean { export function startsWith(stringToTest: string, prefix: string): boolean { return stringToTest.indexOf(prefix) === 0; } + +/** + * Returns the input number of characters from a desired string but takes into account characters encoded with different byte sizes. + * @param text The text from which to return the subset + * @param startChar The starting character from @param text (zero-based) + * @param numChars The number of characters to return starting from @param startChar + * @returns The resulting slice of characters + */ +export const substringUtf = (text: string, startChar: number, numChars: number) => { + const encoded = new TextEncoder().encode(text); + + let currentChar = 0; + let currentByte = 0; + let startByte = 0; + for (; currentChar < startChar + numChars; ) { + if (currentChar === startChar) { + startByte = currentByte; + } + + /* + Unicode is utf encoded using 1, 2, 3, or 4 bytes + In a byte array, we know how many bytes the character is encoded based on the first byte because it + was developed such that the first byte's range never occurs in any other byte. Subsequent bytes are + always within 128 and 191. So in binary it breaks down like this: + 1 byte: 0xxxxxxx + 2 bytes: 110xxxxx 10xxxxxx + 3 bytes: 1110xxxx 10xxxxxx 10xxxxxx + 4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + */ + switch (true) { + // The originall ASCII set is between 0 (00000000) and 127 (01111111) and those only take up one byte + case encoded[currentByte] >= 0 && encoded[currentByte] <= 127: + currentByte++; + break; + // But if the first byte is within 192 (11000000) and 223 (11011111) then we know the character is two bytes: + case encoded[currentByte] >= 192 && encoded[currentByte] <= 223: + currentByte = currentByte + 2; + break; + // If the first byte is anything within 224 (11100000) and 239 (11101111) then the character is three bytes + case encoded[currentByte] >= 224 && encoded[currentByte] <= 239: + currentByte = currentByte + 3; + break; + // If the first byte is anything within 240 (11110000) and 247 (11110111) then the character is four bytes + case encoded[currentByte] >= 240 && encoded[currentByte] <= 247: + currentByte = currentByte + 4; + break; + // Anything past is an error for now + default: + throw new Error("Unrecognized character"); + } + currentChar++; + } + + return new TextDecoder().decode(encoded.slice(startByte, currentByte)); +}; diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 3e5546f80..b2aa588be 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -85,6 +85,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer { userContext.features.phoenixNotebooks = true; userContext.features.phoenixFeatures = true; } + let explorer: Explorer; if (platform === Platform.Hosted) { explorer = await configureHosted(); @@ -927,6 +928,7 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { collectionCreationDefaults: inputs.defaultCollectionThroughput, isTryCosmosDBSubscription: inputs.isTryCosmosDBSubscription, feedbackPolicies: inputs.feedbackPolicies, + ...(inputs.sessionId && { sessionId: inputs.sessionId }), // Remove conditional once Portal sends sessionId }); if (inputs.isPostgresAccount) { diff --git a/src/hooks/useSidePanel.ts b/src/hooks/useSidePanel.ts index 25b87f346..1e883a8b3 100644 --- a/src/hooks/useSidePanel.ts +++ b/src/hooks/useSidePanel.ts @@ -6,6 +6,7 @@ export interface SidePanelState { hasConsole: boolean; panelContent?: JSX.Element; headerText?: string; + setHeaderText: (headerText: string) => void; openSidePanel: (headerText: string, panelContent: JSX.Element, panelWidth?: string, onClose?: () => void) => void; closeSidePanel: () => void; setPanelHasConsole: (hasConsole: boolean) => void; @@ -15,6 +16,7 @@ export const useSidePanel: UseStore = create((set) => ({ isOpen: false, panelWidth: "440px", hasConsole: true, + setHeaderText: (headerText: string) => set((state) => ({ ...state, headerText })), setPanelHasConsole: (hasConsole: boolean) => set((state) => ({ ...state, hasConsole })), openSidePanel: (headerText, panelContent, panelWidth = "440px") => set((state) => ({ ...state, headerText, panelContent, panelWidth, isOpen: true })), diff --git a/utils/cleanupDBs.js b/utils/cleanupDBs.js index 723ec1c73..e0a42d946 100644 --- a/utils/cleanupDBs.js +++ b/utils/cleanupDBs.js @@ -28,7 +28,7 @@ async function main() { const mongoDatabases = await client.mongoDBResources.listMongoDBDatabases(resourceGroupName, account.name); for (const database of mongoDatabases) { // Unfortunately Mongo does not provide a timestamp in ARM. There is no way to tell how old the DB is other thn encoding it in the ID :( - const timestamp = Number(database.name.split("-")[1]); + const timestamp = Number(database.name.split("_").pop()); if (timestamp && timestamp < thirtyMinutesAgo) { await client.mongoDBResources.deleteMongoDBDatabase(resourceGroupName, account.name, database.name); console.log(`DELETED: ${account.name} | ${database.name} | Age: ${friendlyTime(Date.now() - timestamp)}`);