mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-05-21 04:37:28 +01:00
Users/chskelt/pkupdate (#2479)
* Initial change for online partition key change * Refactoring container copy strings so they can be locallized * Missed a file * Fixing some issues found by lint * Fixing errors * Fixing unit tests * Fixing error caused by merging from master * Fixing minor error from merge * Fixing merge error * Addressing comments * Addressing some PR comments * Minor issues * Refixing a formatting issue * Fixed localization error
This commit is contained in:
@@ -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<void> => {
|
||||
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<void> => {
|
||||
const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName);
|
||||
updateDataTransferJob(resumeResult);
|
||||
};
|
||||
|
||||
export const completeDataTransferJob = async (
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
accountName: string,
|
||||
jobName: string,
|
||||
): Promise<void> => {
|
||||
const completeResult: DataTransferJobGetResults = await complete(
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
accountName,
|
||||
jobName,
|
||||
);
|
||||
updateDataTransferJob(completeResult);
|
||||
removeFromPolling(completeResult?.properties?.jobName);
|
||||
};
|
||||
|
||||
const createPayload = (
|
||||
apiType: ApiType,
|
||||
databaseName: string,
|
||||
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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", () => {
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
|
||||
+115
-15
@@ -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(<PartitionKeyComponent {...props} />);
|
||||
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(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
render(<PartitionKeyComponent {...props} />);
|
||||
expect(screen.getByText("/id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders read-only component without change button", () => {
|
||||
const { props } = setupTest();
|
||||
const { container } = render(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
||||
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(<PartitionKeyComponent {...props} />);
|
||||
// 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(<PartitionKeyComponent {...props} />);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PartitionKeyComponentProps> = ({
|
||||
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<PartitionKeyComponentProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
{t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)}
|
||||
<br />
|
||||
<b>{jobName}</b>
|
||||
</Stack.Item>
|
||||
<Stack.Item>{t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)}</Stack.Item>
|
||||
</Stack>
|
||||
) : action === CopyJobActions.complete ? (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
{t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)}
|
||||
<br />
|
||||
<b>{jobName}</b>
|
||||
</Stack.Item>
|
||||
<Stack.Item>{t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)}</Stack.Item>
|
||||
</Stack>
|
||||
) : 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<PartitionKeyComponentProps> = ({
|
||||
},
|
||||
}}
|
||||
></ProgressIndicator>
|
||||
{isCurrentJobInProgress(portalDataTransferJob) && (
|
||||
<DefaultButton
|
||||
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
|
||||
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
|
||||
/>
|
||||
)}
|
||||
{isCurrentJobInProgress(portalDataTransferJob) &&
|
||||
(isOnlineJob(portalDataTransferJob) ? (
|
||||
<IconButton
|
||||
data-test="online-job-action-menu"
|
||||
role="button"
|
||||
iconProps={{
|
||||
iconName: "More",
|
||||
styles: { root: { fontSize: "20px", fontWeight: "bold" } },
|
||||
}}
|
||||
menuProps={getOnlineJobMenuProps(portalDataTransferJob)}
|
||||
menuIconProps={{ iconName: "", className: "hidden" }}
|
||||
ariaLabel={t(Keys.containerCopy.monitorJobs.columns.actions)}
|
||||
title={t(Keys.containerCopy.monitorJobs.columns.actions)}
|
||||
/>
|
||||
) : (
|
||||
<DefaultButton
|
||||
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
|
||||
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
-271
@@ -1,271 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"maxWidth": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontSize": 16,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Change partition key
|
||||
</Text>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Current partition key
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Partitioning
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
data-test="partition-key-values"
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Non-hierarchical
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<StyledMessageBar
|
||||
data-test="partition-key-warning"
|
||||
messageBarIconProps={
|
||||
{
|
||||
"className": "messageBarWarningIcon",
|
||||
"iconName": "WarningSolid",
|
||||
}
|
||||
}
|
||||
messageBarType={5}
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"selectors": {
|
||||
"&.ms-MessageBar--warning": {
|
||||
"backgroundColor": "var(--colorStatusWarningBackground1)",
|
||||
"border": "1px solid var(--colorStatusWarningBorder1)",
|
||||
},
|
||||
".ms-MessageBar-icon": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
".ms-MessageBar-text": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
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.
|
||||
<StyledLinkBase
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
|
||||
style={
|
||||
{
|
||||
"color": "var(--colorBrandForeground1)",
|
||||
}
|
||||
}
|
||||
target="_blank"
|
||||
underline={true}
|
||||
>
|
||||
Learn more
|
||||
</StyledLinkBase>
|
||||
</StyledMessageBar>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
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.
|
||||
</Text>
|
||||
<CustomizedPrimaryButton
|
||||
data-test="change-partition-key-button"
|
||||
onClick={[Function]}
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"width": "fit-content",
|
||||
},
|
||||
}
|
||||
}
|
||||
text="Change"
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"maxWidth": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Current partition key
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Partitioning
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
data-test="partition-key-values"
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Non-hierarchical
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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 ? <div data-testid="loading-overlay">{label}</div> : 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(
|
||||
<ChangePartitionKeyPane
|
||||
sourceDatabase={mockDatabase}
|
||||
sourceCollection={mockCollection}
|
||||
explorer={mockExplorer}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<ChangePartitionKeyPaneProps> = ({
|
||||
sourceDatabase,
|
||||
sourceCollection,
|
||||
@@ -52,6 +71,118 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
const [isExecuting, setIsExecuting] = React.useState<boolean>(false);
|
||||
const [subPartitionKeys, setSubPartitionKeys] = React.useState<string[]>([]);
|
||||
const [partitionKey, setPartitionKey] = React.useState<string>();
|
||||
const [onlineMode, setOnlineMode] = React.useState<boolean>(false);
|
||||
|
||||
// Pane-local account state for tracking prerequisite enablement
|
||||
const [localAccount, setLocalAccount] = React.useState<DataModels.DatabaseAccount>(userContext.databaseAccount);
|
||||
const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState<boolean>(false);
|
||||
const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState<string>("");
|
||||
|
||||
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<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshAccount = async (): Promise<DataModels.DatabaseAccount | null> => {
|
||||
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<ChangePartitionKeyPaneProps> = ({
|
||||
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<ChangePartitionKeyPaneProps> = ({
|
||||
sourceCollectionName: sourceCollection.id(),
|
||||
targetDatabaseName: sourceDatabase.id(),
|
||||
targetCollectionName: targetCollectionId,
|
||||
mode: getModeForApi(),
|
||||
};
|
||||
await initiateDataTransfer(dataTransferParams);
|
||||
};
|
||||
@@ -133,12 +273,18 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
return !!selectedDatabase?.offer();
|
||||
};
|
||||
|
||||
const isSubmitDisabled = onlineMode && !onlinePrerequisitesMet;
|
||||
|
||||
const migrationTypeLowercase = getModeForApi().toLowerCase() as keyof typeof Keys.containerCopy.migrationType;
|
||||
const migrationTypeContent = Keys.containerCopy.migrationType[migrationTypeLowercase];
|
||||
|
||||
return (
|
||||
<RightPaneForm
|
||||
formError={formError}
|
||||
isExecuting={isExecuting}
|
||||
onSubmit={submit}
|
||||
submitButtonText={t(Keys.common.ok)}
|
||||
isSubmitButtonDisabled={isSubmitDisabled}
|
||||
>
|
||||
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
@@ -151,11 +297,58 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
{/* Migration Type */}
|
||||
<Stack data-test="migration-type-section">
|
||||
<Text className="panelTextBold" variant="small" style={{ marginBottom: 4 }}>
|
||||
{t(Keys.containerCopy.migrationTypeTitle)}
|
||||
</Text>
|
||||
<Stack className="panelGroupSpacing" horizontal verticalAlign="center">
|
||||
<div role="radiogroup">
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!onlineMode}
|
||||
aria-label="Offline mode"
|
||||
aria-checked={!onlineMode}
|
||||
name="migrationType"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="migrationTypeOffline"
|
||||
tabIndex={0}
|
||||
onChange={() => setOnlineMode(false)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.containerCopy.migrationType.offline.title)}</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={onlineMode}
|
||||
aria-label="Online mode"
|
||||
aria-checked={onlineMode}
|
||||
name="migrationType"
|
||||
type="radio"
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={() => setOnlineMode(true)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.containerCopy.migrationType.online.title)}</span>
|
||||
</div>
|
||||
</Stack>
|
||||
{migrationTypeContent && (
|
||||
<Text
|
||||
variant="small"
|
||||
style={{ color: "var(--colorNeutralForeground1)", marginTop: 4 }}
|
||||
data-test={`migration-type-description-${migrationTypeLowercase}`}
|
||||
>
|
||||
<MarkdownRender source={t(migrationTypeContent.description)} linkTarget="_blank" />
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Database id
|
||||
{t(Keys.panes.addDatabase.databaseIdLabel)}
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
@@ -420,6 +613,89 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Online prerequisites section */}
|
||||
{onlineMode && (
|
||||
<Stack data-test="online-prerequisites-section" tokens={{ childrenGap: 10 }}>
|
||||
<LoadingOverlay isLoading={isEnablingPrerequisite} label={prerequisiteLoaderMessage} />
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{t(Keys.containerCopy.assignPermissions.onlineConfiguration.title)}
|
||||
</Text>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.containerCopy.assignPermissions.onlineConfiguration.description, { accountName })}
|
||||
</Text>
|
||||
|
||||
{/* Point In Time Restore */}
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
|
||||
<Icon
|
||||
iconName={pitrEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
|
||||
styles={{
|
||||
root: { color: pitrEnabled ? "green" : "var(--colorNeutralForeground1)", fontSize: 16 },
|
||||
}}
|
||||
/>
|
||||
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.containerCopy.pointInTimeRestore.title)}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!pitrEnabled && (
|
||||
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.containerCopy.pointInTimeRestore.description, { accessName: accountName })}
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
data-test="enable-pitr-button"
|
||||
text={t(Keys.containerCopy.pointInTimeRestore.buttonText)}
|
||||
disabled={isEnablingPrerequisite}
|
||||
onClick={handleEnablePitr}
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Online Copy Enabled */}
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
|
||||
<Icon
|
||||
iconName={onlineCopyFeatureEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
|
||||
styles={{
|
||||
root: {
|
||||
color: onlineCopyFeatureEnabled ? "green" : "var(--colorNeutralForeground1)",
|
||||
fontSize: 16,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.containerCopy.onlineCopyEnabled.title)}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!onlineCopyFeatureEnabled && (
|
||||
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.containerCopy.onlineCopyEnabled.description, { accountName })} 
|
||||
<Link href={t(Keys.containerCopy.onlineCopyEnabled.href)} target="_blank" rel="noopener noreferrer">
|
||||
{t(Keys.containerCopy.onlineCopyEnabled.hrefText)}
|
||||
</Link>
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
data-test="enable-online-copy-button"
|
||||
text={t(Keys.containerCopy.onlineCopyEnabled.buttonText)}
|
||||
disabled={isEnablingPrerequisite || !pitrEnabled}
|
||||
onClick={handleEnableOnlineCopy}
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!onlinePrerequisitesMet && (
|
||||
<MessageBar messageBarType={MessageBarType.warning} data-test="online-prerequisites-warning">
|
||||
{t(Keys.containerCopy.onlineCopyEnabled.onlineMigrationPrerequisitesMessage)}
|
||||
</MessageBar>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</RightPaneForm>
|
||||
);
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user