diff --git a/src/Common/dataAccess/dataTransfers.ts b/src/Common/dataAccess/dataTransfers.ts index 3e8829486..0f9d9fac3 100644 --- a/src/Common/dataAccess/dataTransfers.ts +++ b/src/Common/dataAccess/dataTransfers.ts @@ -1,11 +1,15 @@ import { configContext } from "ConfigContext"; +import { Keys, t } from "Localization"; import { ApiType, userContext } from "UserContext"; import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils"; import { cancel, + complete, create, get, listByDatabaseAccount, + pause, + resume, } from "Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; import { CosmosCassandraDataTransferDataSourceSink, @@ -31,6 +35,7 @@ export interface DataTransferParams { sourceCollectionName: string; targetDatabaseName: string; targetCollectionName: string; + mode?: "Offline" | "Online"; } export const getDataTransferJobs = async ( @@ -80,6 +85,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise< sourceCollectionName, targetDatabaseName, targetCollectionName, + mode, } = params; const sourcePayload = createPayload(apiType, sourceDatabaseName, sourceCollectionName); const targetPayload = createPayload(apiType, targetDatabaseName, targetCollectionName); @@ -87,6 +93,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise< properties: { source: sourcePayload, destination: targetPayload, + ...(mode ? { mode } : {}), }, }; return create(subscriptionId, resourceGroupName, accountName, jobName, body); @@ -137,30 +144,52 @@ const pollDataTransferJobOperation = async ( if (status === "Cancelled") { removeFromPolling(jobName); clearMessage && clearMessage(); - const cancelMessage = `Data transfer job ${jobName} cancelled`; + const cancelMessage = t(Keys.containerCopy.dataTransfers.polling.cancelConsoleMessage, { jobName: jobName }); NotificationConsoleUtils.logConsoleError(cancelMessage); throw new AbortError(cancelMessage); } + if (status === "Paused") { + removeFromPolling(jobName); + clearMessage && clearMessage(); + NotificationConsoleUtils.logConsoleInfo( + t(Keys.containerCopy.dataTransfers.polling.pauseConsoleMessage, { jobName: jobName }), + ); + return body; + } if (status === "Failed" || status === "Faulted") { removeFromPolling(jobName); const errorMessage = body?.properties?.error ? JSON.stringify(body?.properties?.error) - : "Operation could not be completed"; + : t(Keys.containerCopy.dataTransfers.polling.defaultErrorMessage); const error = new Error(errorMessage); clearMessage && clearMessage(); - NotificationConsoleUtils.logConsoleError(`Data transfer job ${jobName} failed: ${errorMessage}`); + NotificationConsoleUtils.logConsoleError( + t(Keys.containerCopy.dataTransfers.polling.errorConsoleMessage, { + errorMessage: errorMessage, + jobName: jobName, + }), + ); throw new AbortError(error); } if (status === "Completed") { removeFromPolling(jobName); clearMessage && clearMessage(); - NotificationConsoleUtils.logConsoleInfo(`Data transfer job ${jobName} completed`); + NotificationConsoleUtils.logConsoleInfo( + t(Keys.containerCopy.dataTransfers.polling.completedConsoleMessage, { + jobName: jobName, + }), + ); return body; } const processedCount = body.properties.processedCount; const totalCount = body.properties.totalCount; - const retryMessage = `Data transfer job ${jobName} in progress, total count: ${totalCount}, processed count: ${processedCount}`; - throw new Error(retryMessage); + throw new Error( + t(Keys.containerCopy.dataTransfers.polling.retryConsoleMessage, { + jobName: jobName, + processedCount: processedCount, + totalCount: totalCount, + }), + ); }; export const cancelDataTransferJob = async ( @@ -174,6 +203,43 @@ export const cancelDataTransferJob = async ( removeFromPolling(cancelResult?.properties?.jobName); }; +export const pauseDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const pauseResult: DataTransferJobGetResults = await pause(subscriptionId, resourceGroupName, accountName, jobName); + updateDataTransferJob(pauseResult); + removeFromPolling(pauseResult?.properties?.jobName); +}; + +export const resumeDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName); + updateDataTransferJob(resumeResult); +}; + +export const completeDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const completeResult: DataTransferJobGetResults = await complete( + subscriptionId, + resourceGroupName, + accountName, + jobName, + ); + updateDataTransferJob(completeResult); + removeFromPolling(completeResult?.properties?.jobName); +}; + const createPayload = ( apiType: ApiType, databaseName: string, diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index 8296e0add..5d8ba6c29 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -5,7 +5,7 @@ import RefreshIcon from "../../../../images/refresh-cosmos.svg"; import SunIcon from "../../../../images/SunIcon.svg"; import { configContext, Platform } from "../../../ConfigContext"; import { useThemeStore } from "../../../hooks/useTheme"; -import { Keys, t } from "../../../Localization"; +import { Keys, t } from "Localization"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; import * as Actions from "../Actions/CopyJobActions"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx index 927840b86..c86183ac1 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { CopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes"; import AddReadWritePermissionToDefaultIdentity from "./AddReadWritePermissionToDefaultIdentity"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx index 4c35e306f..f0cd65cf3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { render, RenderResult } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { CopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx index 295c0d633..cd1bb48d5 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import PopoverMessage from "./PopoverContainer"; jest.mock("../../../../../Common/LoadingOverlay", () => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx index 9d9aa07fe..0cd8c4292 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx @@ -1,11 +1,11 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Keys, t } from "Localization"; import React from "react"; import { configContext, Platform } from "../../../../../../ConfigContext"; import { DatabaseAccount } from "../../../../../../Contracts/DataModels"; import * as useDatabaseAccountsHook from "../../../../../../hooks/useDatabaseAccounts"; import { apiType, userContext } from "../../../../../../UserContext"; -import { Keys, t } from "Localization"; import { CopyJobContext } from "../../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx index 03c07ee02..afc275709 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { useCopyJobContext } from "../../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; import { MigrationType } from "./MigrationType"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx index 5da34fd23..f84ab0edb 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes"; import { DatabaseContainerSection } from "./DatabaseContainerSection"; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx index dfde4ec78..ebebb89f7 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx @@ -1,23 +1,85 @@ -import { shallow } from "enzyme"; +import { render, screen, waitFor } from "@testing-library/react"; +import { DatabaseAccount } from "Contracts/DataModels"; import { PartitionKeyComponent, PartitionKeyComponentProps, } from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent"; -import Explorer from "Explorer/Explorer"; +import { useDataTransferJobs } from "hooks/useDataTransferJobs"; import React from "react"; +import { updateUserContext } from "UserContext"; +import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types"; +import Explorer from "../../../Explorer"; + +jest.mock("Common/dataAccess/dataTransfers", () => ({ + cancelDataTransferJob: jest.fn().mockResolvedValue(undefined), + pauseDataTransferJob: jest.fn().mockResolvedValue(undefined), + resumeDataTransferJob: jest.fn().mockResolvedValue(undefined), + completeDataTransferJob: jest.fn().mockResolvedValue(undefined), + pollDataTransferJob: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("hooks/useDataTransferJobs", () => ({ + useDataTransferJobs: jest.fn(() => ({ dataTransferJobs: [] })), + refreshDataTransferJobs: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("hooks/useSidePanel", () => ({ + useSidePanel: { + getState: () => ({ + openSidePanel: jest.fn(), + }), + }, +})); + +jest.mock("ConfigContext", () => ({ + configContext: { platform: "Portal" }, + Platform: { Emulator: "Emulator", Portal: "Portal" }, +})); + +jest.mock("Explorer/Explorer", () => { + return jest.fn().mockImplementation(() => ({ + refreshAllDatabases: jest.fn(), + refreshExplorer: jest.fn(), + })); +}); + +const mockOfflineJob = { + properties: { + jobName: "Portal_test_123", + source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" }, + destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" }, + status: "InProgress", + processedCount: 50, + totalCount: 100, + mode: "Offline" as const, + }, +} as DataTransferJobGetResults; + +const mockOnlineJob = { + properties: { + jobName: "Portal_test_456", + source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" }, + destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" }, + status: "InProgress", + processedCount: 50, + totalCount: 100, + mode: "Online" as const, + }, +} as DataTransferJobGetResults; describe("PartitionKeyComponent", () => { - // Create a test setup function to get fresh instances for each test const setupTest = () => { - // Create an instance of the mocked Explorer const explorer = new Explorer(); - // Create minimal mock objects for database and collection // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockCollection = {} as any as import("../../../../Contracts/ViewModels").Collection; + const mockCollection = { + id: jest.fn().mockReturnValue("testCol"), + databaseId: "testDb", + partitionKey: { kind: "Hash", paths: ["/id"], version: 2 }, + partitionKeyProperties: ["id"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as import("../../../../Contracts/ViewModels").Collection; - // Create props with the mocked Explorer instance const props: PartitionKeyComponentProps = { database: mockDatabase, collection: mockCollection, @@ -27,15 +89,53 @@ describe("PartitionKeyComponent", () => { return { explorer, props }; }; - it("renders default component and matches snapshot", () => { - const { props } = setupTest(); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); + updateUserContext({ + databaseAccount: { + name: "testAccount", + id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount", + properties: { + documentEndpoint: "https://test.documents.azure.com", + }, + } as unknown as DatabaseAccount, + subscriptionId: "sub1", + resourceGroup: "rg1", + }); }); - it("renders read-only component and matches snapshot", () => { + it("renders partition key value", () => { const { props } = setupTest(); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + render(); + expect(screen.getByText("/id")).toBeTruthy(); + }); + + it("renders read-only component without change button", () => { + const { props } = setupTest(); + const { container } = render(); + expect(container.querySelector("[data-test='change-partition-key-button']")).toBeNull(); + }); + + it("shows cancel button for offline job in progress", () => { + (useDataTransferJobs as unknown as jest.Mock).mockReturnValue({ + dataTransferJobs: [mockOfflineJob], + }); + + const { props } = setupTest(); + const { container } = render(); + // For offline jobs, the online action menu should not be present + expect(container.querySelector("[data-test='online-job-action-menu']")).toBeNull(); + }); + + it("shows ellipsis action menu for online job in progress", async () => { + (useDataTransferJobs as unknown as jest.Mock).mockReturnValue({ + dataTransferJobs: [mockOnlineJob], + }); + + const { props } = setupTest(); + const { container } = render(); + await waitFor(() => { + expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy(); + }); }); }); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx index d276d6371..75053f91c 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx @@ -1,7 +1,10 @@ import { DefaultButton, + DirectionalHint, FontWeights, + IContextualMenuProps, IMessageBarStyles, + IconButton, Link, MessageBar, MessageBarType, @@ -14,8 +17,16 @@ import * as React from "react"; import * as ViewModels from "../../../../Contracts/ViewModels"; import { handleError } from "Common/ErrorHandlingUtils"; -import { cancelDataTransferJob, pollDataTransferJob } from "Common/dataAccess/dataTransfers"; +import { + cancelDataTransferJob, + completeDataTransferJob, + pauseDataTransferJob, + pollDataTransferJob, + resumeDataTransferJob, +} from "Common/dataAccess/dataTransfers"; import { Platform, configContext } from "ConfigContext"; +import { CopyJobActions, CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import { useDialog } from "Explorer/Controls/Dialog"; import Explorer from "Explorer/Explorer"; import { ChangePartitionKeyPane } from "Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane"; import { Keys, t } from "Localization"; @@ -94,11 +105,11 @@ export const PartitionKeyComponent: React.FC = ({ const textSubHeadingStyle1 = { root: { color: "var(--colorNeutralForeground1)" }, }; - const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => { + const startPollingforUpdate = async (currentJob: DataTransferJobGetResults) => { if (isCurrentJobInProgress(currentJob)) { const jobName = currentJob?.properties?.jobName; try { - pollDataTransferJob( + await pollDataTransferJob( jobName, userContext.subscriptionId, userContext.resourceGroup, @@ -119,6 +130,124 @@ export const PartitionKeyComponent: React.FC = ({ ); }; + const pauseRunningDataTransferJob = async (currentJob: DataTransferJobGetResults) => { + await pauseDataTransferJob( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + currentJob?.properties?.jobName, + ); + }; + + const resumePausedDataTransferJob = async (currentJob: DataTransferJobGetResults) => { + await resumeDataTransferJob( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + currentJob?.properties?.jobName, + ); + startPollingforUpdate(currentJob); + }; + + const completeOnlineDataTransferJob = async (currentJob: DataTransferJobGetResults) => { + await completeDataTransferJob( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + currentJob?.properties?.jobName, + ); + }; + + const isOnlineJob = (currentJob: DataTransferJobGetResults): boolean => { + const mode = (currentJob?.properties?.mode ?? "").toLowerCase(); + return mode === CopyJobMigrationType.Online; + }; + + const showActionConfirmationDialog = ( + currentJob: DataTransferJobGetResults, + action: CopyJobActions, + onConfirm: () => void, + ): void => { + const jobName = currentJob?.properties?.jobName; + const dialogBody = + action === CopyJobActions.cancel ? ( + + + {t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)} +
+ {jobName} +
+ {t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)} +
+ ) : action === CopyJobActions.complete ? ( + + + {t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)} +
+ {jobName} +
+ {t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)} +
+ ) : null; + + useDialog + .getState() + .showOkCancelModalDialog("", null, t(Keys.common.confirm), onConfirm, t(Keys.common.cancel), null, dialogBody); + }; + + const getOnlineJobMenuProps = (currentJob: DataTransferJobGetResults): IContextualMenuProps => { + const jobStatus = currentJob?.properties?.status; + const isPaused = jobStatus === "Paused"; + + const items: IContextualMenuProps["items"] = []; + + if (!isPaused) { + items.push({ + key: CopyJobActions.pause, + text: t(Keys.containerCopy.monitorJobs.actions.pause), + iconProps: { iconName: "Pause" }, + onClick: () => { + pauseRunningDataTransferJob(currentJob); + }, + }); + } + + if (isPaused) { + items.push({ + key: CopyJobActions.resume, + text: t(Keys.containerCopy.monitorJobs.actions.resume), + iconProps: { iconName: "Play" }, + onClick: () => { + resumePausedDataTransferJob(currentJob); + }, + }); + } + + items.push({ + key: CopyJobActions.cancel, + text: t(Keys.common.cancel), + iconProps: { iconName: "Cancel" }, + onClick: () => + showActionConfirmationDialog(currentJob, CopyJobActions.cancel, () => cancelRunningDataTransferJob(currentJob)), + }); + + items.push({ + key: CopyJobActions.complete, + text: t(Keys.containerCopy.monitorJobs.actions.complete), + iconProps: { iconName: "CheckMark" }, + onClick: () => + showActionConfirmationDialog(currentJob, CopyJobActions.complete, () => + completeOnlineDataTransferJob(currentJob), + ), + }); + + return { + items, + directionalHint: DirectionalHint.leftTopEdge, + directionalHintFixed: false, + }; + }; + const isCurrentJobInProgress = (currentJob: DataTransferJobGetResults) => { const jobStatus = currentJob?.properties?.status; return ( @@ -269,12 +398,26 @@ export const PartitionKeyComponent: React.FC = ({ }, }} > - {isCurrentJobInProgress(portalDataTransferJob) && ( - cancelRunningDataTransferJob(portalDataTransferJob)} - /> - )} + {isCurrentJobInProgress(portalDataTransferJob) && + (isOnlineJob(portalDataTransferJob) ? ( + + ) : ( + cancelRunningDataTransferJob(portalDataTransferJob)} + /> + ))} )} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap deleted file mode 100644 index 3c75bf55f..000000000 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap +++ /dev/null @@ -1,271 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = ` - - - - Change partition key - - - - - Current partition key - - - Partitioning - - - - - - Non-hierarchical - - - - - - To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process. - - Learn more - - - - To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container. - - - -`; - -exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = ` - - - - - - Current partition key - - - Partitioning - - - - - - Non-hierarchical - - - - - -`; diff --git a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx index ea05388ae..716d90a99 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx @@ -291,7 +291,7 @@ describe("SettingsUtils", () => { it("handles partition key tab title based on fabric native", () => { // Assuming initially not fabric native - expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)"); + expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys"); }); it("throws error for unknown tab type", () => { diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 6e0990849..5d8e2766c 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -2,7 +2,6 @@ import { Keys, t } from "Localization"; import * as Constants from "../../../Common/Constants"; import * as DataModels from "../../../Contracts/DataModels"; import * as ViewModels from "../../../Contracts/ViewModels"; -import { isFabricNative } from "../../../Platform/Fabric/FabricUtil"; import { userContext } from "../../../UserContext"; import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils"; import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types"; @@ -185,9 +184,7 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => { case SettingsV2TabTypes.IndexingPolicyTab: return t(Keys.controls.settings.tabTitles.indexingPolicy); case SettingsV2TabTypes.PartitionKeyTab: - return isFabricNative() - ? t(Keys.controls.settings.tabTitles.partitionKeys) - : t(Keys.controls.settings.tabTitles.partitionKeysPreview); + return t(Keys.controls.settings.tabTitles.partitionKeys); case SettingsV2TabTypes.ComputedPropertiesTab: return t(Keys.controls.settings.tabTitles.computedProperties); case SettingsV2TabTypes.ContainerVectorPolicyTab: diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 5913f938f..53ab9c658 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -429,7 +429,7 @@ exports[`SettingsComponent renders 1`] = ` "data-test": "settings-tab-header/PartitionKeyTab", } } - headerText="Partition Keys (preview)" + headerText="Partition Keys" itemKey="PartitionKeyTab" key="PartitionKeyTab" style={ diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx new file mode 100644 index 000000000..67b9a2c0d --- /dev/null +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx @@ -0,0 +1,218 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { initiateDataTransfer } from "Common/dataAccess/dataTransfers"; +import { DatabaseAccount } from "Contracts/DataModels"; +import * as ViewModels from "Contracts/ViewModels"; +import Explorer from "Explorer/Explorer"; +import * as React from "react"; +import { updateUserContext } from "UserContext"; +import { ChangePartitionKeyPane } from "./ChangePartitionKeyPane"; + +jest.mock("Common/ErrorHandlingUtils", () => ({ + handleError: jest.fn(), + getErrorMessage: jest.fn().mockReturnValue("error"), + getErrorStack: jest.fn().mockReturnValue(""), +})); + +jest.mock("Common/dataAccess/createCollection", () => ({ + createCollection: jest.fn().mockResolvedValue({}), +})); + +jest.mock("Common/dataAccess/readDatabases", () => ({ + readDatabases: jest.fn().mockResolvedValue([]), +})); + +jest.mock("Common/dataAccess/dataTransfers", () => ({ + initiateDataTransfer: jest.fn().mockResolvedValue({}), +})); + +jest.mock("Utils/arm/databaseAccountUtils", () => ({ + fetchDatabaseAccount: jest.fn().mockResolvedValue(null), +})); + +jest.mock("Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({ + update: jest.fn().mockResolvedValue({}), +})); + +jest.mock("hooks/useSidePanel", () => ({ + useSidePanel: { + getState: () => ({ + closeSidePanel: jest.fn(), + openSidePanel: jest.fn(), + }), + }, +})); + +jest.mock("Explorer/useDatabases", () => { + const state: Record = { + databases: [], + resourceTokenCollection: undefined, + resourceTokenDatabase: undefined, + sampleDataResourceTokenCollection: undefined, + }; + const mockStore = Object.assign( + jest.fn(() => state), + { + getState: () => state, + setState: jest.fn(), + subscribe: jest.fn(), + destroy: jest.fn(), + }, + ); + return { useDatabases: mockStore }; +}); + +jest.mock("Common/LoadingOverlay", () => { + return { + __esModule: true, + default: ({ isLoading, label }: { isLoading: boolean; label: string }) => + isLoading ?
{label}
: null, + }; +}); + +const createMockCollection = (id: string): ViewModels.Collection => { + const mockCollection = { + id: jest.fn().mockReturnValue(id), + offer: jest.fn().mockReturnValue(undefined), + partitionKey: { kind: "Hash", paths: ["/id"], version: 2 }, + partitionKeyProperties: ["id"], + databaseId: "testDb", + } as unknown as ViewModels.Collection; + return mockCollection; +}; + +const createMockDatabase = (id: string, collections: ViewModels.Collection[] = []): ViewModels.Database => { + return { + id: jest.fn().mockReturnValue(id), + collections: jest.fn().mockReturnValue(collections), + } as unknown as ViewModels.Database; +}; + +describe("ChangePartitionKeyPane", () => { + const mockExplorer = new Explorer(); + const mockOnClose = jest.fn().mockResolvedValue(undefined); + const mockCollection = createMockCollection("testCollection"); + const mockDatabase = createMockDatabase("testDb", [mockCollection]); + + beforeEach(() => { + jest.clearAllMocks(); + updateUserContext({ + databaseAccount: { + name: "testAccount", + id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount", + properties: { + documentEndpoint: "https://test.documents.azure.com", + capabilities: [], + backupPolicy: { type: "Periodic" }, + }, + } as unknown as DatabaseAccount, + subscriptionId: "sub1", + resourceGroup: "rg1", + apiType: "SQL", + }); + }); + + const renderPane = () => { + return render( + , + ); + }; + + it("renders migration type choice group", () => { + renderPane(); + expect(screen.getByText("Migration type")).toBeTruthy(); + expect(screen.getByText("Offline mode")).toBeTruthy(); + expect(screen.getByText("Online mode")).toBeTruthy(); + }); + + it("defaults to offline migration type", () => { + renderPane(); + const offlineRadio = screen.getByRole("radio", { name: "Offline mode" }) as HTMLInputElement; + expect(offlineRadio.checked).toBe(true); + }); + + it("does not show online prerequisites section when offline is selected", () => { + const { container } = renderPane(); + expect(container.querySelector("[data-test='online-prerequisites-section']")).toBeNull(); + }); + + it("shows online prerequisites section when online is selected", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + expect(screen.getByText("Online container copy")).toBeTruthy(); + expect(screen.getByText("Point In Time Restore enabled")).toBeTruthy(); + expect(screen.getByText("Online copy enabled")).toBeTruthy(); + }); + + it("shows prerequisite sections when online prerequisites are not met", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + // When prerequisites aren't met, the enable buttons should be visible + expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Enable Online Copy" }).length).toBeGreaterThan(0); + }); + + it("shows enable PITR button when PITR is not enabled", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy(); + }); + + it("does not show PITR enable button when PITR is already enabled", () => { + updateUserContext({ + databaseAccount: { + name: "testAccount", + id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount", + properties: { + documentEndpoint: "https://test.documents.azure.com", + capabilities: [], + backupPolicy: { type: "Continuous" }, + }, + } as unknown as DatabaseAccount, + }); + + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + expect(screen.queryByText("Enable Point In Time Restore")).toBeNull(); + }); + + it("disables online copy button when PITR is not enabled", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + const enableOnlineCopyBtns = screen.getAllByRole("button", { name: "Enable Online Copy" }); + expect(enableOnlineCopyBtns.length).toBeGreaterThan(0); + expect((enableOnlineCopyBtns[0] as HTMLButtonElement).disabled).toBe(true); + }); + + it("passes mode to initiateDataTransfer when submitting", async () => { + const mockInitiateDataTransfer = jest.mocked(initiateDataTransfer); + // Mock refreshAllDatabases on the prototype to catch all calls + const refreshSpy = jest.spyOn(Explorer.prototype, "refreshAllDatabases").mockResolvedValue(); + const { container } = renderPane(); + + // Fill in partition key (required for createContainer — state starts undefined) + const partitionKeyInput = container.querySelector("#addCollection-partitionKeyValue") as HTMLInputElement; + expect(partitionKeyInput).not.toBeNull(); + fireEvent.change(partitionKeyInput, { target: { value: "/myKey" } }); + + const form = container.querySelector("form"); + expect(form).not.toBeNull(); + + await act(async () => { + fireEvent.submit(form!); + }); + + expect(mockInitiateDataTransfer).toHaveBeenCalled(); + expect(mockInitiateDataTransfer.mock.calls[0][0].mode).toBe("Offline"); + refreshSpy.mockRestore(); + }); +}); diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx index 5a91c6755..80089092d 100644 --- a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx @@ -7,16 +7,24 @@ import { IconButton, Link, MessageBar, + MessageBarType, + PrimaryButton, Stack, Text, TooltipHost, } from "@fluentui/react"; +import MarkdownRender from "@nteract/markdown"; import * as Constants from "Common/Constants"; +import { CapabilityNames } from "Common/Constants"; import { handleError } from "Common/ErrorHandlingUtils"; +import LoadingOverlay from "Common/LoadingOverlay"; +import { logError } from "Common/Logger"; import { createCollection } from "Common/dataAccess/createCollection"; import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers"; import * as DataModels from "Contracts/DataModels"; import * as ViewModels from "Contracts/ViewModels"; +import { buildResourceLink } from "Explorer/ContainerCopy/CopyJobUtils"; +import { BackupPolicyType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; import { getPartitionKeyName, getPartitionKeyPlaceHolder, @@ -30,6 +38,8 @@ import { Keys, t } from "Localization"; import { userContext } from "UserContext"; import { getCollectionName } from "Utils/APITypeUtils"; import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import { update as updateDatabaseAccount } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useSidePanel } from "hooks/useSidePanel"; import * as React from "react"; @@ -40,6 +50,15 @@ export interface ChangePartitionKeyPaneProps { onClose: () => Promise; } +const checkPitrEnabled = (account: DataModels.DatabaseAccount): boolean => { + return account?.properties?.backupPolicy?.type === BackupPolicyType.Continuous; +}; + +const checkOnlineCopyEnabled = (account: DataModels.DatabaseAccount): boolean => { + const capabilities = account?.properties?.capabilities ?? []; + return capabilities.some((cap) => cap.name === CapabilityNames.EnableOnlineCopyFeature); +}; + export const ChangePartitionKeyPane: React.FC = ({ sourceDatabase, sourceCollection, @@ -52,6 +71,118 @@ export const ChangePartitionKeyPane: React.FC = ({ const [isExecuting, setIsExecuting] = React.useState(false); const [subPartitionKeys, setSubPartitionKeys] = React.useState([]); const [partitionKey, setPartitionKey] = React.useState(); + const [onlineMode, setOnlineMode] = React.useState(false); + + // Pane-local account state for tracking prerequisite enablement + const [localAccount, setLocalAccount] = React.useState(userContext.databaseAccount); + const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState(false); + const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState(""); + + const pitrEnabled = checkPitrEnabled(localAccount); + const onlineCopyFeatureEnabled = checkOnlineCopyEnabled(localAccount); + const onlinePrerequisitesMet = pitrEnabled && onlineCopyFeatureEnabled; + + const accountName = localAccount?.name ?? ""; + const subscriptionId = userContext.subscriptionId; + const resourceGroup = userContext.resourceGroup; + + const intervalRef = React.useRef(null); + const timeoutRef = React.useRef(null); + + React.useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const refreshAccount = async (): Promise => { + try { + const account = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName); + if (account) { + setLocalAccount(account); + } + return account; + } catch (error) { + logError( + error.message || "Error fetching account after enabling prerequisite.", + "ChangePartitionKey/refreshAccount", + ); + return null; + } + }; + + const clearPollingTimers = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const startPollingForAccountUpdate = () => { + intervalRef.current = setInterval(() => { + refreshAccount(); + }, 30 * 1000); + + timeoutRef.current = setTimeout( + () => { + clearPollingTimers(); + setIsEnablingPrerequisite(false); + }, + 10 * 60 * 1000, + ); + }; + + const handleEnablePitr = () => { + const sourceAccountLink = buildResourceLink(localAccount); + const featureUrl = `${sourceAccountLink}/backupRestore`; + setIsEnablingPrerequisite(true); + setPrerequisiteLoaderMessage(t(Keys.containerCopy.popoverOverlaySpinnerLabel)); + window.open(featureUrl, "_blank"); + startPollingForAccountUpdate(); + }; + + const handleEnableOnlineCopy = async () => { + setIsEnablingPrerequisite(true); + try { + setPrerequisiteLoaderMessage( + t(Keys.containerCopy.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel), + ); + const currentAccount = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName); + if (!currentAccount?.properties?.enableAllVersionsAndDeletesChangeFeed) { + setPrerequisiteLoaderMessage( + t(Keys.containerCopy.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel), + ); + await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, { + properties: { + enableAllVersionsAndDeletesChangeFeed: true, + }, + }); + } + const capabilities = currentAccount?.properties?.capabilities ?? []; + setPrerequisiteLoaderMessage( + t(Keys.containerCopy.onlineCopyEnabled.enablingOnlineCopySpinnerLabel, { accountName }), + ); + await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, { + properties: { + capabilities: [...capabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], + }, + }); + startPollingForAccountUpdate(); + } catch (error) { + logError(error.message || "Failed to enable online copy feature.", "ChangePartitionKey/handleEnableOnlineCopy"); + setFormError("Failed to enable online copy feature. Please try again."); + setIsEnablingPrerequisite(false); + } + }; const getCollectionOptions = (): IDropdownOption[] => { return sourceDatabase @@ -84,9 +215,17 @@ export const ChangePartitionKeyPane: React.FC = ({ setFormError("Choose an existing container"); return false; } + if (onlineMode && !onlinePrerequisitesMet) { + setFormError("Online migration prerequisites must be enabled before proceeding."); + return false; + } return true; }; + const getModeForApi = (): "Offline" | "Online" => { + return onlineMode ? "Online" : "Offline"; + }; + const createDataTransferJob = async () => { const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`; const dataTransferParams: DataTransferParams = { @@ -99,6 +238,7 @@ export const ChangePartitionKeyPane: React.FC = ({ sourceCollectionName: sourceCollection.id(), targetDatabaseName: sourceDatabase.id(), targetCollectionName: targetCollectionId, + mode: getModeForApi(), }; await initiateDataTransfer(dataTransferParams); }; @@ -133,12 +273,18 @@ export const ChangePartitionKeyPane: React.FC = ({ return !!selectedDatabase?.offer(); }; + const isSubmitDisabled = onlineMode && !onlinePrerequisitesMet; + + const migrationTypeLowercase = getModeForApi().toLowerCase() as keyof typeof Keys.containerCopy.migrationType; + const migrationTypeContent = Keys.containerCopy.migrationType[migrationTypeLowercase]; + return ( @@ -151,11 +297,58 @@ export const ChangePartitionKeyPane: React.FC = ({ {t(Keys.common.learnMore)} + + {/* Migration Type */} + + + {t(Keys.containerCopy.migrationTypeTitle)} + + +
+ setOnlineMode(false)} + /> + {t(Keys.containerCopy.migrationType.offline.title)} + + setOnlineMode(true)} + /> + {t(Keys.containerCopy.migrationType.online.title)} +
+
+ {migrationTypeContent && ( + + + + )} +
+ - Database id + {t(Keys.panes.addDatabase.databaseIdLabel)} = ({ /> )} + + {/* Online prerequisites section */} + {onlineMode && ( + + + + {t(Keys.containerCopy.assignPermissions.onlineConfiguration.title)} + + + {t(Keys.containerCopy.assignPermissions.onlineConfiguration.description, { accountName })} + + + {/* Point In Time Restore */} + + + + + {t(Keys.containerCopy.pointInTimeRestore.title)} + + + {!pitrEnabled && ( + + + {t(Keys.containerCopy.pointInTimeRestore.description, { accessName: accountName })} + + + + )} + + + {/* Online Copy Enabled */} + + + + + {t(Keys.containerCopy.onlineCopyEnabled.title)} + + + {!onlineCopyFeatureEnabled && ( + + + {t(Keys.containerCopy.onlineCopyEnabled.description, { accountName })}  + + {t(Keys.containerCopy.onlineCopyEnabled.hrefText)} + + + + + )} + + + {!onlinePrerequisitesMet && ( + + {t(Keys.containerCopy.onlineCopyEnabled.onlineMigrationPrerequisitesMessage)} + + )} + + )}
); diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index c04104ff8..26fb72021 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -760,7 +760,6 @@ "settings": "Settings", "indexingPolicy": "Indexing Policy", "partitionKeys": "Partition Keys", - "partitionKeysPreview": "Partition Keys (preview)", "computedProperties": "Computed Properties", "containerPolicies": "Container Policies", "throughputBuckets": "Throughput Buckets", @@ -895,6 +894,10 @@ }, "partitionKeyEditor": { "changePartitionKey": "Change {{partitionKeyName}}", + "confirmCancel1": "You are about to cancel the following copy job.", + "confirmCancel2": "Cancelling will stop the job immediately.", + "confirmComplete1": "You are about to complete the following copy job.", + "confrimComplete2": "Once completed, continuous data copy will stop after any pending documents are processed. To maintain data integrity, we recommend stopping updates to the source container before completing the job.", "currentPartitionKey": "Current {{partitionKeyName}}", "partitioning": "Partitioning", "hierarchical": "Hierarchical", @@ -1033,6 +1036,7 @@ "description": "Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)." } }, + "migrationTypeTitle": "Migration type", "selectContainers": { "description": "Please select a source container and a destination container to copy to.", "sourceContainerSubHeading": "Source container", @@ -1117,7 +1121,8 @@ "buttonText": "Enable Online Copy", "validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validating All versions and deletes change feed mode (preview)...", "enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Enabling All versions and deletes change feed mode (preview)...", - "enablingOnlineCopySpinnerLabel": "Enabling online copy on your \"{{accountName}}\" account ..." + "enablingOnlineCopySpinnerLabel": "Enabling online copy on your \"{{accountName}}\" account ...", + "onlineMigrationPrerequisitesMessage": "Online migration prerequisites must be enabled before proceeding." }, "monitorJobs": { "columns": { @@ -1152,6 +1157,16 @@ "confirmButtonText": "Confirm", "cancelButtonText": "Cancel" } + }, + "dataTransfers": { + "polling": { + "cancelConsoleMessage": "Data transfer job \"{{jobName}}\" cancelled", + "completedConsoleMessage": "Data transfer job \"{{jobName}}\" completed", + "defaultErrorMessage": "Operation could not be completed", + "errorConsoleMessage": "Data transfer job \"{{jobName}}\" failed: {{errorMessage}}", + "pauseConsoleMessage": "Data transfer job \"{{jobName}}\" paused", + "retryConsoleMessage": "Data transfer job \"{{jobName}}\" in progress, total count: {{totalCount}}, processed count: {{processedCount}}" + } } } -} +} \ No newline at end of file