Refactor Container Copy Jobs for Intra-account copy and Online operations (#2258)

* fix: for intra-account copy, validation screen should not visible

* fix: handle online operations using a button instead manual CLI commands

* reset validation cache on leaving of permission screen

* update same account logic

* fix: update job action menu list and permission screen messages

* uplift error handling to context level

* use of logError instead of console.error
This commit is contained in:
BChoudhury-ms
2025-11-19 22:41:13 +05:30
committed by GitHub
parent beccab02e7
commit 125b1c86b7
16 changed files with 166 additions and 92 deletions

View File

@@ -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<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
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<AddReadPermissionToDefaultIde
}));
}
} catch (error) {
console.error("Error assigning read permission to default identity:", error);
const errorMessage =
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
setContextError(errorMessage);
} finally {
setLoading(false);
}
}, [copyJobState, setCopyJobState]);
}, [copyJobState, setCopyJobState, setContextError]);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>

View File

@@ -6,6 +6,7 @@ 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";
@@ -39,6 +40,8 @@ const AssignPermissions = () => {
[],
);
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
useEffect(() => {
const firstIncompleteSection = permissionSections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
@@ -49,7 +52,13 @@ const AssignPermissions = () => {
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
<span>{ContainerCopyMessages.assignPermissions.description}</span>
<span>
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
)
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
</span>
{permissionSections?.length === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (

View File

@@ -1,7 +1,10 @@
import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { CapabilityNames } from "Common/Constants";
import { DatabaseAccount } from "Contracts/DataModels";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
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 +22,10 @@ const OnlineCopyEnabled: React.FC = () => {
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(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 +43,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 +69,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);
@@ -89,32 +126,7 @@ const OnlineCopyEnabled: React.FC = () => {
</Link>
</Stack.Item>
<Stack.Item>
<pre style={{ backgroundColor: "#f5f5f5", padding: "10px", borderRadius: "4px", overflow: "auto" }}>
<code>
{`# Set shell variables
$resourceGroupName = <azure_resource_group>
$accountName = <azure_cosmos_db_account_name>
$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`}
</code>
</pre>
</Stack.Item>
{showRefreshButton && (
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
@@ -122,8 +134,16 @@ az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourc
onClick={handleRefresh}
disabled={loading}
/>
</Stack.Item>
)}
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={handleOnlineCopyEnable}
/>
)}
</Stack.Item>
</Stack>
);
};

View File

@@ -2,6 +2,7 @@ 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 { logError } from "../../../../../Common/Logger";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
@@ -63,17 +64,23 @@ 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);
};
@@ -95,7 +102,7 @@ const PointInTimeRestore: React.FC = () => {
() => {
clearIntervalAndShowRefresh();
},
15 * 60 * 1000,
10 * 60 * 1000,
);
};

View File

@@ -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<boolean>(false);
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
@@ -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);
}

View File

@@ -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, isIntraAccountCopy } from "../../../../CopyJobUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
@@ -139,7 +139,9 @@ const usePermissionSections = (state: CopyJobContextState): PermissionSectionCon
const isValidatingRef = useRef(false);
const sectionToValidate = useMemo(() => {
const baseSections = sourceAccountId === targetAccountId ? [] : [...PERMISSION_SECTIONS_CONFIG];
const isSameAccount = isIntraAccountCopy(sourceAccountId, targetAccountId);
const baseSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
if (state.migrationType === CopyJobMigrationType.Online) {
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
}

View File

@@ -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,24 +13,23 @@ const CreateCopyJobScreens: React.FC = () => {
handlePrevious,
handleCancel,
primaryBtnText,
error,
setError,
} = useCopyJobNavigation();
const { contextError, setContextError } = useCopyJobContext();
return (
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
<Stack.Item className="createCopyJobScreensContent">
{error && (
{contextError && (
<MessageBar
className="createCopyJobErrorMessageBar"
messageBarType={MessageBarType.blocked}
isMultiline={false}
onDismiss={() => setError(null)}
onDismiss={() => setContextError(null)}
dismissButtonAriaLabel="Close"
truncated={true}
overflowButtonAriaLabel="See more"
>
{error}
{contextError}
</MessageBar>
)}
{currentScreen?.component}

View File

@@ -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<string, boolean>());
},
[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<string, boolean>());
},
[setCopyJobState],
[setCopyJobState, setValidationCache],
);
return { handleSelectSourceAccount, handleMigrationTypeChange };

View File

@@ -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 { isIntraAccountCopy } from "../../CopyJobUtils";
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
@@ -33,8 +34,7 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt
export function useCopyJobNavigation() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { copyJobState, resetCopyJobState } = useCopyJobContext();
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
const screens = useCreateCopyJobScreensList();
const { validationCache: cache } = useCopyJobPrerequisitesCache();
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
@@ -71,18 +71,13 @@ export function useCopyJobNavigation() {
containerId: container?.containerId || "",
});
const isSameAccount = (
sourceIds: ReturnType<typeof getContainerIdentifiers>,
targetIds: ReturnType<typeof getContainerIdentifiers>,
) => 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,7 +101,7 @@ 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);
}
@@ -113,11 +109,13 @@ export function useCopyJobNavigation() {
const handlePrimary = useCallback(() => {
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
@@ -146,7 +144,5 @@ export function useCopyJobNavigation() {
handlePrevious,
handleCancel,
primaryBtnText,
error,
setError,
};
}