mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-28 05:11:31 +00:00
Container Copy Job implementation for SQL accounts (#2241)
* Initial dev for container copy * remove padding from label * Added Copy Job prerequisites screen * Added hooks to evaluate reader role access * added copyjob pre-requsite screen along with it's validations * Added monitor copy job list screen * added copy job list refresh and reset functionality * remove arm token dependency * fetch account details from account id instead of context * Fix lint & typescript checks * show copyjob screen from portal navigation * adding copy job details screen * remove duplicate code & show sql accounts only * ui fixes for list job page * pending icon * copy job details screen ui * reset .vscode/settings.json * Fixed existing UTs * disabling action buttons until it's in progress * fixed formatting * Adding loader on submit button and show job creation errors in the panel itself * updating disabling action menu item logic * added custom pager * fix lint and ts errors * updating file names and removing comments * remove comments * modularize the arom common code * Adding content and removing tooltip * updating job details screen * updating online copy enabled screen * Adding below changes - Don't show permission screen for same account in offline mode - Don't show identity permissions for same account in online mode - Show error message if selected containers are identical - Update abort signal messages * added feedback code from explorer * Add tooltips and long polling - Added tooltips to permission sections - Implemented long polling for PITR and online copy enabled sections - Long polling automatically stops after 15 minutes - After polling ends, a refresh button will be displayed --------- Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
This commit is contained in:
205
src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx
Normal file
205
src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import {
|
||||
cancel,
|
||||
complete,
|
||||
create,
|
||||
listByDatabaseAccount,
|
||||
pause,
|
||||
resume,
|
||||
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
||||
import {
|
||||
CreateJobRequest,
|
||||
DataTransferJobGetResults,
|
||||
} from "../../../Utils/arm/generatedClients/dataTransferService/types";
|
||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
||||
import {
|
||||
convertTime,
|
||||
convertToCamelCase,
|
||||
COSMOS_SQL_COMPONENT,
|
||||
extractErrorMessage,
|
||||
formatUTCDateTime,
|
||||
getAccountDetailsFromResourceId,
|
||||
} from "../CopyJobUtils";
|
||||
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
|
||||
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
|
||||
|
||||
export const openCreateCopyJobPanel = () => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
||||
<CreateCopyJobScreensProvider />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
|
||||
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
|
||||
<CopyJobDetails job={job} />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
|
||||
let copyJobsAbortController: AbortController | null = null;
|
||||
|
||||
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
try {
|
||||
if (copyJobsAbortController) {
|
||||
copyJobsAbortController.abort();
|
||||
}
|
||||
copyJobsAbortController = new AbortController();
|
||||
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const response = await listByDatabaseAccount(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
copyJobsAbortController.signal,
|
||||
);
|
||||
|
||||
const jobs = response.value || [];
|
||||
if (!Array.isArray(jobs)) {
|
||||
throw new Error("Invalid migration job status response: Expected an array of jobs.");
|
||||
}
|
||||
copyJobsAbortController = null;
|
||||
|
||||
/* added a lower bound to "0" and upper bound to "100" */
|
||||
const calculateCompletionPercentage = (processed: number, total: number): number => {
|
||||
if (
|
||||
typeof processed !== "number" ||
|
||||
typeof total !== "number" ||
|
||||
!isFinite(processed) ||
|
||||
!isFinite(total) ||
|
||||
total <= 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const percentage = Math.round((processed / total) * 100);
|
||||
return Math.max(0, Math.min(100, percentage));
|
||||
};
|
||||
|
||||
const formattedJobs: CopyJobType[] = jobs
|
||||
.filter(
|
||||
(job: DataTransferJobGetResults) =>
|
||||
job.properties?.source?.component === COSMOS_SQL_COMPONENT &&
|
||||
job.properties?.destination?.component === COSMOS_SQL_COMPONENT,
|
||||
)
|
||||
.sort(
|
||||
(current: DataTransferJobGetResults, next: DataTransferJobGetResults) =>
|
||||
new Date(next.properties.lastUpdatedUtcTime).getTime() -
|
||||
new Date(current.properties.lastUpdatedUtcTime).getTime(),
|
||||
)
|
||||
.map((job: DataTransferJobGetResults, index: number) => {
|
||||
const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime);
|
||||
|
||||
return {
|
||||
ID: (index + 1).toString(),
|
||||
Mode: job.properties.mode,
|
||||
Name: job.properties.jobName,
|
||||
Source: job.properties.source,
|
||||
Destination: job.properties.destination,
|
||||
Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"],
|
||||
CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount),
|
||||
Duration: convertTime(job.properties.duration),
|
||||
LastUpdatedTime: dateTimeObj.formattedDateTime,
|
||||
timestamp: dateTimeObj.timestamp,
|
||||
Error: job.properties.error ? extractErrorMessage(job.properties.error as unknown as CopyJobErrorType) : null,
|
||||
} as CopyJobType;
|
||||
});
|
||||
return formattedJobs;
|
||||
} catch (error) {
|
||||
const errorContent = JSON.stringify(error.content || error.message || error);
|
||||
if (errorContent.includes("signal is aborted without reason")) {
|
||||
throw {
|
||||
message:
|
||||
"Please wait for the current fetch request to complete. The previous copy job fetch request was aborted.",
|
||||
};
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => {
|
||||
try {
|
||||
const { source, target, migrationType, jobName } = state;
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const body = {
|
||||
properties: {
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
remoteAccountName: source?.account?.name,
|
||||
databaseName: source?.databaseId,
|
||||
containerName: source?.containerId,
|
||||
},
|
||||
destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: target?.databaseId,
|
||||
containerName: target?.containerId,
|
||||
},
|
||||
mode: migrationType,
|
||||
},
|
||||
} as unknown as CreateJobRequest;
|
||||
|
||||
const response = await create(subscriptionId, resourceGroup, accountName, jobName, body);
|
||||
MonitorCopyJobsRefState.getState().ref?.refreshJobList();
|
||||
onSuccess();
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error submitting create copy job:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobGetResults> => {
|
||||
try {
|
||||
let updateFn = null;
|
||||
switch (action.toLowerCase()) {
|
||||
case CopyJobActions.pause:
|
||||
updateFn = pause;
|
||||
break;
|
||||
case CopyJobActions.resume:
|
||||
updateFn = resume;
|
||||
break;
|
||||
case CopyJobActions.cancel:
|
||||
updateFn = cancel;
|
||||
break;
|
||||
case CopyJobActions.complete:
|
||||
updateFn = complete;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error);
|
||||
|
||||
const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
|
||||
const pattern = new RegExp(`'(${statusList.join("|")})'`, "g");
|
||||
const normalizedErrorMessage = errorMessage.replace(
|
||||
pattern,
|
||||
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
|
||||
);
|
||||
|
||||
console.error(`Error updating copy job status: ${normalizedErrorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
31
src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx
Normal file
31
src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { StyleConstants } from "../../../Common/StyleConstants";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
|
||||
import { ContainerCopyProps } from "../Types/CopyJobTypes";
|
||||
import { getCommandBarButtons } from "./Utils";
|
||||
|
||||
const backgroundColor = StyleConstants.BaseLight;
|
||||
const rootStyle = {
|
||||
root: {
|
||||
backgroundColor: backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
|
||||
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
|
||||
|
||||
return (
|
||||
<div className="commandBarContainer">
|
||||
<FluentCommandBar
|
||||
ariaLabel="Use left and right arrow keys to navigate between commands"
|
||||
styles={rootStyle}
|
||||
items={controlButtons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobCommandBar;
|
||||
59
src/Explorer/ContainerCopy/CommandBar/Utils.ts
Normal file
59
src/Explorer/ContainerCopy/CommandBar/Utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import AddIcon from "../../../../images/Add.svg";
|
||||
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
|
||||
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
|
||||
import { configContext, Platform } from "../../../ConfigContext";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import * as Actions from "../Actions/CopyJobActions";
|
||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
|
||||
|
||||
function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
|
||||
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
|
||||
const buttons: CopyJobCommandBarBtnType[] = [
|
||||
{
|
||||
key: "createCopyJob",
|
||||
iconSrc: AddIcon,
|
||||
label: ContainerCopyMessages.createCopyJobButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
|
||||
onClick: Actions.openCreateCopyJobPanel,
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
iconSrc: RefreshIcon,
|
||||
label: ContainerCopyMessages.refreshButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
|
||||
onClick: () => monitorCopyJobsRef?.refreshJobList(),
|
||||
},
|
||||
];
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
buttons.push({
|
||||
key: "feedback",
|
||||
iconSrc: FeedbackIcon,
|
||||
label: ContainerCopyMessages.feedbackButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
|
||||
onClick: () => {
|
||||
container.openContainerCopyFeedbackBlade();
|
||||
},
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps {
|
||||
return {
|
||||
iconSrc: config.iconSrc,
|
||||
iconAlt: config.label,
|
||||
onCommandClick: config.onClick,
|
||||
commandButtonLabel: undefined as string | undefined,
|
||||
ariaLabel: config.ariaLabel,
|
||||
tooltipText: config.label,
|
||||
hasPopup: false,
|
||||
disabled: config.disabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns(container).map(btnMapper);
|
||||
}
|
||||
154
src/Explorer/ContainerCopy/ContainerCopyMessages.ts
Normal file
154
src/Explorer/ContainerCopy/ContainerCopyMessages.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
export default {
|
||||
// Copy Job Command Bar
|
||||
feedbackButtonLabel: "Feedback",
|
||||
feedbackButtonAriaLabel: "Provide feedback on copy jobs",
|
||||
refreshButtonLabel: "Refresh",
|
||||
refreshButtonAriaLabel: "Refresh copy jobs",
|
||||
createCopyJobButtonLabel: "Create Copy Job",
|
||||
createCopyJobButtonAriaLabel: "Create a new container copy job",
|
||||
|
||||
// No Copy Jobs Found
|
||||
noCopyJobsTitle: "No copy jobs to show",
|
||||
createCopyJobButtonText: "Create a container copy job",
|
||||
|
||||
// Copy Job Details
|
||||
copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details",
|
||||
errorTitle: "Error Details",
|
||||
selectedContainers: "Selected Containers",
|
||||
|
||||
// Create Copy Job Panel
|
||||
createCopyJobPanelTitle: "Create copy job",
|
||||
|
||||
// Select Account Screen
|
||||
selectAccountDescription: "Please select a source account from which to copy.",
|
||||
subscriptionDropdownLabel: "Subscription",
|
||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
||||
sourceAccountDropdownLabel: "Account",
|
||||
sourceAccountDropdownPlaceholder: "Select an account",
|
||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
||||
|
||||
// Select Source and Target Containers Screen
|
||||
selectSourceAndTargetContainersDescription:
|
||||
"Please select a source container and a destination container to copy to.",
|
||||
sourceContainerSubHeading: "Source container",
|
||||
targetContainerSubHeading: "Destination container",
|
||||
databaseDropdownLabel: "Database",
|
||||
databaseDropdownPlaceholder: "Select a database",
|
||||
containerDropdownLabel: "Container",
|
||||
containerDropdownPlaceholder: "Select a container",
|
||||
|
||||
// Preview and Create Screen
|
||||
jobNameLabel: "Job name",
|
||||
sourceSubscriptionLabel: "Source subscription",
|
||||
sourceAccountLabel: "Source account",
|
||||
sourceDatabaseLabel: "Source database",
|
||||
sourceContainerLabel: "Source container",
|
||||
targetDatabaseLabel: "Destination database",
|
||||
targetContainerLabel: "Destination container",
|
||||
|
||||
// Assign Permissions Screen
|
||||
assignPermissions: {
|
||||
description:
|
||||
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
|
||||
},
|
||||
toggleBtn: {
|
||||
onText: "On",
|
||||
offText: "Off",
|
||||
},
|
||||
addManagedIdentity: {
|
||||
title: "System-assigned managed identity enabled.",
|
||||
description:
|
||||
"A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.",
|
||||
descriptionHrefText: "Learn more about Managed identities.",
|
||||
descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
toggleLabel: "System assigned managed identity",
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Managed Identities.",
|
||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
},
|
||||
userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.",
|
||||
userAssignedIdentityLabel: "You may also select a user assigned managed identity.",
|
||||
createUserAssignedIdentityLink: "Create User Assigned Managed Identity",
|
||||
enablementTitle: "Enable system assigned managed identity",
|
||||
enablementDescription: (accountName: string) =>
|
||||
accountName
|
||||
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. `
|
||||
: "",
|
||||
},
|
||||
defaultManagedIdentity: {
|
||||
title: "System-assigned managed identity set as default.",
|
||||
description: (accountName: string) =>
|
||||
`Set the system-assigned managed identity as default for "${accountName}" by switching it on.`,
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Default Managed Identities.",
|
||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
},
|
||||
popoverTitle: "System assigned managed identity set as default",
|
||||
popoverDescription: (accountName: string) =>
|
||||
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
|
||||
},
|
||||
readPermissionAssigned: {
|
||||
title: "Read permissions assigned to the default identity.",
|
||||
description:
|
||||
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Read permissions.",
|
||||
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
|
||||
},
|
||||
popoverTitle: "Read permissions assigned to default identity.",
|
||||
popoverDescription:
|
||||
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ",
|
||||
},
|
||||
pointInTimeRestore: {
|
||||
title: "Point In Time Restore enabled",
|
||||
description: (accessName: string) =>
|
||||
`To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`,
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Continuous Backup",
|
||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
|
||||
},
|
||||
buttonText: "Enable Point In Time Restore",
|
||||
},
|
||||
onlineCopyEnabled: {
|
||||
title: "Online copy enabled",
|
||||
description: (accountName: string) => `Use Azure CLI to enable Online copy on "${accountName}".`,
|
||||
hrefText: "Learn more about online copy jobs",
|
||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
|
||||
buttonText: "Enable Online Copy",
|
||||
},
|
||||
MonitorJobs: {
|
||||
Columns: {
|
||||
lastUpdatedTime: "Date & time",
|
||||
name: "Job name",
|
||||
status: "Status",
|
||||
completionPercentage: "Completion %",
|
||||
duration: "Duration",
|
||||
error: "Error message",
|
||||
mode: "Mode",
|
||||
actions: "Actions",
|
||||
},
|
||||
Actions: {
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
cancel: "Cancel",
|
||||
complete: "Complete",
|
||||
viewDetails: "View Details",
|
||||
},
|
||||
Status: {
|
||||
Pending: "Pending",
|
||||
InProgress: "In Progress",
|
||||
Running: "In Progress",
|
||||
Partitioning: "In Progress",
|
||||
Paused: "Paused",
|
||||
Completed: "Completed",
|
||||
Failed: "Failed",
|
||||
Faulted: "Failed",
|
||||
Skipped: "Cancelled",
|
||||
Cancelled: "Cancelled",
|
||||
},
|
||||
},
|
||||
};
|
||||
23
src/Explorer/ContainerCopy/ContainerCopyPanel.tsx
Normal file
23
src/Explorer/ContainerCopy/ContainerCopyPanel.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { useEffect } from "react";
|
||||
import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar";
|
||||
import "./containerCopyStyles.less";
|
||||
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
|
||||
import { ContainerCopyProps } from "./Types/CopyJobTypes";
|
||||
|
||||
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
|
||||
useEffect(() => {
|
||||
if (monitorCopyJobsRef.current) {
|
||||
MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current);
|
||||
}
|
||||
}, [monitorCopyJobsRef.current]);
|
||||
return (
|
||||
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
|
||||
<CopyJobCommandBar container={container} />
|
||||
<MonitorCopyJobs ref={monitorCopyJobsRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerCopyPanel;
|
||||
54
src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
Normal file
54
src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
|
||||
|
||||
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
|
||||
export const useCopyJobContext = (): CopyJobContextProviderType => {
|
||||
const context = React.useContext(CopyJobContext);
|
||||
if (!context) {
|
||||
throw new Error("useCopyJobContext must be used within a CopyJobContextProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface CopyJobContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const getInitialCopyJobState = (): CopyJobContextState => {
|
||||
return {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: null,
|
||||
account: null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: userContext.subscriptionId || "",
|
||||
account: userContext.databaseAccount || null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
};
|
||||
};
|
||||
|
||||
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
|
||||
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
|
||||
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
|
||||
|
||||
const resetCopyJobState = () => {
|
||||
setCopyJobState(getInitialCopyJobState());
|
||||
};
|
||||
|
||||
return (
|
||||
<CopyJobContext.Provider value={{ copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
|
||||
{props.children}
|
||||
</CopyJobContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobContextProvider;
|
||||
116
src/Explorer/ContainerCopy/CopyJobUtils.ts
Normal file
116
src/Explorer/ContainerCopy/CopyJobUtils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobErrorType } from "./Types/CopyJobTypes";
|
||||
|
||||
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
||||
|
||||
export const buildResourceLink = (resource: DatabaseAccount): string => {
|
||||
const resourceId = resource.id;
|
||||
let parentOrigin = window.location.ancestorOrigins?.[0] ?? window.location.origin;
|
||||
|
||||
if (/\/\/localhost:/.test(parentOrigin)) {
|
||||
parentOrigin = azurePortalMpacEndpoint;
|
||||
} else if (/\/\/cosmos\.azure/.test(parentOrigin)) {
|
||||
parentOrigin = parentOrigin.replace("cosmos.azure", "portal.azure");
|
||||
}
|
||||
|
||||
parentOrigin = parentOrigin.replace(/\/$/, "");
|
||||
|
||||
return `${parentOrigin}/#resource${resourceId}`;
|
||||
};
|
||||
|
||||
export const COSMOS_SQL_COMPONENT = "CosmosDBSql";
|
||||
|
||||
export const COPY_JOB_API_VERSION = "2025-05-01-preview";
|
||||
|
||||
export function buildDataTransferJobPath({
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
jobName,
|
||||
action,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
resourceGroup: string;
|
||||
accountName: string;
|
||||
jobName?: string;
|
||||
action?: string;
|
||||
}) {
|
||||
let path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`;
|
||||
if (jobName) {
|
||||
path += `/${jobName}`;
|
||||
}
|
||||
if (action) {
|
||||
path += `/${action}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function convertTime(timeStr: string): string | null {
|
||||
const timeParts = timeStr.split(":").map(Number);
|
||||
|
||||
if (timeParts.length !== 3 || timeParts.some(isNaN)) {
|
||||
return null;
|
||||
}
|
||||
const formatPart = (value: number, unit: string) => {
|
||||
if (unit === "seconds") {
|
||||
value = Math.round(value);
|
||||
}
|
||||
return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : "";
|
||||
};
|
||||
|
||||
const [hours, minutes, seconds] = timeParts;
|
||||
const formattedTimeParts = [
|
||||
formatPart(hours, "hours"),
|
||||
formatPart(minutes, "minutes"),
|
||||
formatPart(seconds, "seconds"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return formattedTimeParts || "0 seconds";
|
||||
}
|
||||
|
||||
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
|
||||
const date = new Date(utcStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formattedDateTime: new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(date),
|
||||
timestamp: date.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToCamelCase(str: string): string {
|
||||
const formattedStr = str
|
||||
.split(/\s+/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
return formattedStr;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType {
|
||||
return {
|
||||
...error,
|
||||
message: error.message.split("\r\n\r\n")[0],
|
||||
};
|
||||
}
|
||||
|
||||
export function getAccountDetailsFromResourceId(accountId: string | undefined) {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
const pattern = new RegExp(
|
||||
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB/databaseAccounts/([^/]+)",
|
||||
"i",
|
||||
);
|
||||
const matches = accountId.match(pattern);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
|
||||
return { subscriptionId, resourceGroup, accountName };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
import PopoverMessage from "../Components/PopoverContainer";
|
||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const [systemAssigned, onToggle] = useToggle(false);
|
||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
|
||||
|
||||
return (
|
||||
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Text>
|
||||
{ContainerCopyMessages.addManagedIdentity.description} 
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
|
||||
</Link>{" "}
|
||||
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</Text>
|
||||
<Toggle
|
||||
checked={systemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={systemAssigned}
|
||||
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddManagedIdentity;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
import PopoverMessage from "../Components/PopoverContainer";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const TooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const [readPermissionAssigned, onToggle] = useToggle(false);
|
||||
|
||||
const handleAddReadPermission = useCallback(async () => {
|
||||
const { source, target } = copyJobState;
|
||||
const selectedSourceAccount = source?.account;
|
||||
try {
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
setLoading(true);
|
||||
const assignedRole = await assignRole(
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
target?.account?.identity?.principalId ?? "",
|
||||
);
|
||||
if (assignedRole) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
sourceReadAccessFromTarget: true,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error assigning read permission to default identity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [copyJobState, setCopyJobState]);
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Text className="toggle-label">
|
||||
{ContainerCopyMessages.readPermissionAssigned.description} 
|
||||
<InfoTooltip content={TooltipContent} />
|
||||
</Text>
|
||||
<Toggle
|
||||
checked={readPermissionAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
inlineLabel
|
||||
styles={{
|
||||
root: { marginTop: 8, marginBottom: 12 },
|
||||
label: { display: "none" },
|
||||
}}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={readPermissionAssigned}
|
||||
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddReadPermission}
|
||||
>
|
||||
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddReadPermissionToDefaultIdentity;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Image, Stack, Text } from "@fluentui/react";
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
|
||||
import React, { useEffect } from "react";
|
||||
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
|
||||
import WarningIcon from "../../../../../../images/warning.svg";
|
||||
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
|
||||
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
|
||||
<AccordionItem key={id} value={id} disabled={disabled}>
|
||||
<AccordionHeader className="accordionHeader">
|
||||
<Text className="accordionHeaderText" variant="medium">
|
||||
{title}
|
||||
</Text>
|
||||
<Image
|
||||
className="statusIcon"
|
||||
src={completed ? CheckmarkIcon : WarningIcon}
|
||||
alt={completed ? "Checkmark icon" : "Warning icon"}
|
||||
width={completed ? 20 : 24}
|
||||
height={completed ? 20 : 24}
|
||||
/>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
|
||||
<Component />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
const AssignPermissions = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const permissionSections = usePermissionSections(copyJobState);
|
||||
const [openItems, setOpenItems] = React.useState<string[]>([]);
|
||||
|
||||
const indentLevels = React.useMemo<IndentLevel[]>(
|
||||
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const firstIncompleteSection = permissionSections.find((section) => !section.completed);
|
||||
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
|
||||
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
|
||||
setOpenItems(nextOpenItems);
|
||||
}
|
||||
}, [permissionSections]);
|
||||
|
||||
return (
|
||||
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
|
||||
<span>{ContainerCopyMessages.assignPermissions.description}</span>
|
||||
{permissionSections?.length === 0 ? (
|
||||
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
|
||||
) : (
|
||||
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
|
||||
{permissionSections.map((section) => (
|
||||
<PermissionSection key={section.id} {...section} />
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignPermissions;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
import PopoverMessage from "../Components/PopoverContainer";
|
||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const [defaultSystemAssigned, onToggle] = useToggle(false);
|
||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">
|
||||
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</div>
|
||||
<Toggle
|
||||
checked={defaultSystemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
inlineLabel
|
||||
styles={{
|
||||
root: { marginTop: 8, marginBottom: 12 },
|
||||
label: { display: "none" },
|
||||
}}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={defaultSystemAssigned}
|
||||
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultManagedIdentity;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Link, PrimaryButton, Stack } from "@fluentui/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
||||
|
||||
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
|
||||
const prevCapabilities = prev?.properties?.capabilities ?? [];
|
||||
const nextCapabilities = next?.properties?.capabilities ?? [];
|
||||
|
||||
return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities);
|
||||
};
|
||||
|
||||
const OnlineCopyEnabled: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
||||
const selectedSourceAccount = source?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
const handleFetchAccount = async () => {
|
||||
try {
|
||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
source: { ...prevState.source, account: account },
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching source account after enabling online copy:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearIntervalAndShowRefresh = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setShowRefreshButton(true);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
handleFetchAccount();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
handleFetchAccount();
|
||||
}, 30 * 1000);
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
clearIntervalAndShowRefresh();
|
||||
},
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Stack.Item className="info-message">
|
||||
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")} 
|
||||
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
|
||||
</Link>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<pre style={{ backgroundColor: "#f5f5f5", padding: "10px", borderRadius: "4px", overflow: "auto" }}>
|
||||
<code>
|
||||
{`# Set shell variables
|
||||
$resourceGroupName = <azure_resource_group>
|
||||
$accountName = <azure_cosmos_db_account_name>
|
||||
$EnableOnlineContainerCopy = "EnableOnlineContainerCopy"
|
||||
|
||||
# List down existing capabilities of your account
|
||||
$cosmosdb = az cosmosdb show --resource-group $resourceGroupName --name $accountName
|
||||
|
||||
$capabilities = (($cosmosdb | ConvertFrom-Json).capabilities)
|
||||
|
||||
# Append EnableOnlineContainerCopy capability in the list of capabilities
|
||||
$capabilitiesToAdd = @()
|
||||
foreach ($item in $capabilities) {
|
||||
$capabilitiesToAdd += $item.name
|
||||
}
|
||||
$capabilitiesToAdd += $EnableOnlineContainerCopy
|
||||
|
||||
# Update Cosmos DB account
|
||||
az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourceGroupName`}
|
||||
</code>
|
||||
</pre>
|
||||
</Stack.Item>
|
||||
{showRefreshButton && (
|
||||
<Stack.Item>
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={ContainerCopyMessages.refreshButtonLabel}
|
||||
iconProps={{ iconName: "Refresh" }}
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineCopyEnabled;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
|
||||
const tooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
|
||||
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
|
||||
const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? "";
|
||||
const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? "";
|
||||
|
||||
return prevBackupPolicy !== nextBackupPolicy;
|
||||
};
|
||||
|
||||
const PointInTimeRestore: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showRefreshButton, setShowRefreshButton] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
||||
const sourceAccountLink = buildResourceLink(source?.account);
|
||||
const featureUrl = `${sourceAccountLink}/backupRestore`;
|
||||
const selectedSourceAccount = source?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFetchAccount = async () => {
|
||||
try {
|
||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
source: { ...prevState.source, account: account },
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching source account after Point-in-Time Restore:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearIntervalAndShowRefresh = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setLoading(false);
|
||||
setShowRefreshButton(true);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
handleFetchAccount();
|
||||
};
|
||||
|
||||
const openWindowAndMonitor = () => {
|
||||
setLoading(true);
|
||||
setShowRefreshButton(false);
|
||||
window.open(featureUrl, "_blank");
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
handleFetchAccount();
|
||||
}, 30 * 1000);
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
clearIntervalAndShowRefresh();
|
||||
},
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Stack.Item className="toggle-label">
|
||||
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
|
||||
{tooltipContent && (
|
||||
<>
|
||||
{" "}
|
||||
<InfoTooltip content={tooltipContent} />
|
||||
</>
|
||||
)}
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{showRefreshButton ? (
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={ContainerCopyMessages.refreshButtonLabel}
|
||||
iconProps={{ iconName: "Refresh" }}
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
|
||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
disabled={loading}
|
||||
onClick={openWindowAndMonitor}
|
||||
/>
|
||||
)}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointInTimeRestore;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
||||
|
||||
interface UseManagedIdentityUpdaterParams {
|
||||
updateIdentityFn: (
|
||||
subscriptionId: string,
|
||||
resourceGroup?: string,
|
||||
accountName?: string,
|
||||
) => Promise<DatabaseAccount | undefined>;
|
||||
}
|
||||
|
||||
interface UseManagedIdentityUpdaterReturn {
|
||||
loading: boolean;
|
||||
handleAddSystemIdentity: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useManagedIdentity = (
|
||||
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
|
||||
): UseManagedIdentityUpdaterReturn => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const selectedTargetAccount = copyJobState?.target?.account;
|
||||
const {
|
||||
subscriptionId: targetSubscriptionId,
|
||||
resourceGroup: targetResourceGroup,
|
||||
accountName: targetAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
||||
|
||||
const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName);
|
||||
if (updatedAccount) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
target: { ...prevState.target, account: updatedAccount },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error enabling system-assigned managed identity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [copyJobState, updateIdentityFn, setCopyJobState]);
|
||||
|
||||
return { loading, handleAddSystemIdentity };
|
||||
};
|
||||
|
||||
export default useManagedIdentity;
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CapabilityNames } from "../../../../../../Common/Constants";
|
||||
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
||||
import {
|
||||
BackupPolicyType,
|
||||
CopyJobMigrationType,
|
||||
DefaultIdentityType,
|
||||
IdentityType,
|
||||
} from "../../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
|
||||
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
|
||||
import AddManagedIdentity from "../AddManagedIdentity";
|
||||
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
|
||||
import DefaultManagedIdentity from "../DefaultManagedIdentity";
|
||||
import OnlineCopyEnabled from "../OnlineCopyEnabled";
|
||||
import PointInTimeRestore from "../PointInTimeRestore";
|
||||
|
||||
export interface PermissionSectionConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
Component: React.ComponentType;
|
||||
disabled: boolean;
|
||||
completed?: boolean;
|
||||
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const SECTION_IDS = {
|
||||
addManagedIdentity: "addManagedIdentity",
|
||||
defaultManagedIdentity: "defaultManagedIdentity",
|
||||
readPermissionAssigned: "readPermissionAssigned",
|
||||
pointInTimeRestore: "pointInTimeRestore",
|
||||
onlineCopyEnabled: "onlineCopyEnabled",
|
||||
} as const;
|
||||
|
||||
const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
|
||||
{
|
||||
id: SECTION_IDS.addManagedIdentity,
|
||||
title: ContainerCopyMessages.addManagedIdentity.title,
|
||||
Component: AddManagedIdentity,
|
||||
disabled: true,
|
||||
validate: (state: CopyJobContextState) => {
|
||||
const targetAccountIdentityType = (state?.target?.account?.identity?.type ?? "").toLowerCase();
|
||||
return (
|
||||
targetAccountIdentityType === IdentityType.SystemAssigned ||
|
||||
targetAccountIdentityType === IdentityType.UserAssigned
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SECTION_IDS.defaultManagedIdentity,
|
||||
title: ContainerCopyMessages.defaultManagedIdentity.title,
|
||||
Component: DefaultManagedIdentity,
|
||||
disabled: true,
|
||||
validate: (state: CopyJobContextState) => {
|
||||
const targetAccountDefaultIdentity = (state?.target?.account?.properties?.defaultIdentity ?? "").toLowerCase();
|
||||
return targetAccountDefaultIdentity === DefaultIdentityType.SystemAssignedIdentity;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SECTION_IDS.readPermissionAssigned,
|
||||
title: ContainerCopyMessages.readPermissionAssigned.title,
|
||||
Component: AddReadPermissionToDefaultIdentity,
|
||||
disabled: true,
|
||||
validate: async (state: CopyJobContextState) => {
|
||||
const principalId = state?.target?.account?.identity?.principalId;
|
||||
const selectedSourceAccount = state?.source?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
const rolesAssigned = await fetchRoleAssignments(
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
principalId,
|
||||
);
|
||||
|
||||
const roleDefinitions = await fetchRoleDefinitions(rolesAssigned ?? []);
|
||||
return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
|
||||
{
|
||||
id: SECTION_IDS.pointInTimeRestore,
|
||||
title: ContainerCopyMessages.pointInTimeRestore.title,
|
||||
Component: PointInTimeRestore,
|
||||
disabled: true,
|
||||
validate: (state: CopyJobContextState) => {
|
||||
const sourceAccountBackupPolicy = state?.source?.account?.properties?.backupPolicy?.type ?? "";
|
||||
return sourceAccountBackupPolicy === BackupPolicyType.Continuous;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SECTION_IDS.onlineCopyEnabled,
|
||||
title: ContainerCopyMessages.onlineCopyEnabled.title,
|
||||
Component: OnlineCopyEnabled,
|
||||
disabled: true,
|
||||
validate: (state: CopyJobContextState) => {
|
||||
const accountCapabilities = state?.source?.account?.properties?.capabilities ?? [];
|
||||
const onlineCopyCapability = accountCapabilities.find(
|
||||
(capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature,
|
||||
);
|
||||
return !!onlineCopyCapability;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if the user has the Reader role based on role definitions.
|
||||
*/
|
||||
export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean {
|
||||
return roleDefinitions?.some(
|
||||
(role) =>
|
||||
role.name === "00000000-0000-0000-0000-000000000001" ||
|
||||
role.permissions.some(
|
||||
(permission) =>
|
||||
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") &&
|
||||
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the permission sections configuration for the Assign Permissions screen.
|
||||
* Memoizes derived values for performance and decouples logic for testability.
|
||||
*/
|
||||
const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => {
|
||||
const sourceAccountId = state?.source?.account?.id || "";
|
||||
const targetAccountId = state?.target?.account?.id || "";
|
||||
|
||||
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
|
||||
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
|
||||
const isValidatingRef = useRef(false);
|
||||
|
||||
const sectionToValidate = useMemo(() => {
|
||||
const baseSections = sourceAccountId === targetAccountId ? [] : [...PERMISSION_SECTIONS_CONFIG];
|
||||
if (state.migrationType === CopyJobMigrationType.Online) {
|
||||
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
|
||||
}
|
||||
return baseSections;
|
||||
}, [sourceAccountId, targetAccountId, state.migrationType]);
|
||||
|
||||
const memoizedValidationCache = useMemo(() => {
|
||||
if (state.migrationType === CopyJobMigrationType.Offline) {
|
||||
validationCache.delete(SECTION_IDS.pointInTimeRestore);
|
||||
validationCache.delete(SECTION_IDS.onlineCopyEnabled);
|
||||
}
|
||||
return validationCache;
|
||||
}, [state.migrationType]);
|
||||
|
||||
useEffect(() => {
|
||||
const validateSections = async () => {
|
||||
if (isValidatingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isValidatingRef.current = true;
|
||||
const result: PermissionSectionConfig[] = [];
|
||||
const newValidationCache = new Map(memoizedValidationCache);
|
||||
|
||||
for (let i = 0; i < sectionToValidate.length; i++) {
|
||||
const section = sectionToValidate[i];
|
||||
|
||||
if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) {
|
||||
result.push({ ...section, completed: true });
|
||||
continue;
|
||||
}
|
||||
if (section.validate) {
|
||||
const isValid = await section.validate(state);
|
||||
newValidationCache.set(section.id, isValid);
|
||||
result.push({ ...section, completed: isValid });
|
||||
|
||||
if (!isValid) {
|
||||
for (let j = i + 1; j < sectionToValidate.length; j++) {
|
||||
result.push({ ...sectionToValidate[j], completed: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
newValidationCache.set(section.id, false);
|
||||
result.push({ ...section, completed: false });
|
||||
}
|
||||
}
|
||||
|
||||
setValidationCache(newValidationCache);
|
||||
setPermissionSections(result);
|
||||
isValidatingRef.current = false;
|
||||
};
|
||||
|
||||
validateSections();
|
||||
|
||||
return () => {
|
||||
isValidatingRef.current = false;
|
||||
};
|
||||
}, [state, sectionToValidate]);
|
||||
|
||||
return permissionSections ?? [];
|
||||
};
|
||||
|
||||
export default usePermissionSections;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const useToggle = (initialState = false) => {
|
||||
const [state, setState] = useState<boolean>(initialState);
|
||||
const onToggle = useCallback((_, checked?: boolean) => {
|
||||
setState(checked);
|
||||
}, []);
|
||||
return [state, onToggle] as const;
|
||||
};
|
||||
|
||||
export default useToggle;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
interface FieldRowProps {
|
||||
label?: string;
|
||||
children: React.ReactNode;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const FieldRow: React.FC<FieldRowProps> = ({ label = "", children, labelClassName = "" }) => {
|
||||
return (
|
||||
<Stack horizontal horizontalAlign="space-between" className="flex-row">
|
||||
{label && (
|
||||
<Stack.Item align="center" className="flex-fixed-width">
|
||||
<label className={`field-label ${labelClassName}`}>{label}: </label>
|
||||
</Stack.Item>
|
||||
)}
|
||||
<Stack.Item align="center" className="flex-grow-col">
|
||||
{children}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldRow;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import InfoIcon from "../../../../../../images/Info.svg";
|
||||
|
||||
const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const hostStyles: Partial<ITooltipHostStyles> = { root: { display: "inline-block" } };
|
||||
return (
|
||||
<TooltipHost content={content} calloutProps={{ gapSpace: 0 }} styles={hostStyles}>
|
||||
<Image src={InfoIcon} alt="Information" width={14} height={14} />
|
||||
</TooltipHost>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InfoTooltip);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
type NavigationControlsProps = {
|
||||
primaryBtnText: string;
|
||||
onPrimary: () => void;
|
||||
onPrevious: () => void;
|
||||
onCancel: () => void;
|
||||
isPrimaryDisabled: boolean;
|
||||
isPreviousDisabled: boolean;
|
||||
};
|
||||
|
||||
const NavigationControls: React.FC<NavigationControlsProps> = ({
|
||||
primaryBtnText,
|
||||
onPrimary,
|
||||
onPrevious,
|
||||
onCancel,
|
||||
isPrimaryDisabled,
|
||||
isPreviousDisabled,
|
||||
}) => (
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
|
||||
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
|
||||
<DefaultButton text="Cancel" onClick={onCancel} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default React.memo(NavigationControls);
|
||||
@@ -0,0 +1,67 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
interface PopoverContainerProps {
|
||||
isLoading?: boolean;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
onPrimary: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
|
||||
({ isLoading = false, title, children, onPrimary, onCancel }) => {
|
||||
return (
|
||||
<Stack
|
||||
className={`popover-container foreground ${isLoading ? "loading" : ""}`}
|
||||
tokens={{ childrenGap: 20 }}
|
||||
style={{ maxWidth: 450 }}
|
||||
>
|
||||
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text>{children}</Text>
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton
|
||||
text={isLoading ? "" : "Yes"}
|
||||
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
onClick={onPrimary}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface PopoverMessageProps {
|
||||
isLoading?: boolean;
|
||||
visible: boolean;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onPrimary: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PopoverMessage: React.FC<PopoverMessageProps> = ({
|
||||
isLoading = false,
|
||||
visible,
|
||||
title,
|
||||
onCancel,
|
||||
onPrimary,
|
||||
children,
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary} isLoading={isLoading}>
|
||||
{children}
|
||||
</PopoverContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopoverMessage;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
|
||||
import NavigationControls from "./Components/NavigationControls";
|
||||
|
||||
const CreateCopyJobScreens: React.FC = () => {
|
||||
const {
|
||||
currentScreen,
|
||||
isPrimaryDisabled,
|
||||
isPreviousDisabled,
|
||||
handlePrimary,
|
||||
handlePrevious,
|
||||
handleCancel,
|
||||
primaryBtnText,
|
||||
error,
|
||||
setError,
|
||||
} = useCopyJobNavigation();
|
||||
|
||||
return (
|
||||
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
|
||||
<Stack.Item className="createCopyJobScreensContent">
|
||||
{error && (
|
||||
<MessageBar
|
||||
className="createCopyJobErrorMessageBar"
|
||||
messageBarType={MessageBarType.blocked}
|
||||
isMultiline={false}
|
||||
onDismiss={() => setError(null)}
|
||||
dismissButtonAriaLabel="Close"
|
||||
truncated={true}
|
||||
overflowButtonAriaLabel="See more"
|
||||
>
|
||||
{error}
|
||||
</MessageBar>
|
||||
)}
|
||||
{currentScreen?.component}
|
||||
</Stack.Item>
|
||||
<Stack.Item className="createCopyJobScreensFooter">
|
||||
<NavigationControls
|
||||
primaryBtnText={primaryBtnText}
|
||||
onPrimary={handlePrimary}
|
||||
onPrevious={handlePrevious}
|
||||
onCancel={handleCancel}
|
||||
isPrimaryDisabled={isPrimaryDisabled}
|
||||
isPreviousDisabled={isPreviousDisabled}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCopyJobScreens;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import CopyJobContextProvider from "../../Context/CopyJobContext";
|
||||
import CreateCopyJobScreens from "./CreateCopyJobScreens";
|
||||
|
||||
const CreateCopyJobScreensProvider = () => {
|
||||
return (
|
||||
<CopyJobContextProvider>
|
||||
<CreateCopyJobScreens />
|
||||
</CopyJobContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCopyJobScreensProvider;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
|
||||
import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
|
||||
|
||||
const PreviewCopyJob: React.FC = () => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
|
||||
const selectedDatabaseAndContainers = [
|
||||
{
|
||||
sourceDatabaseName: copyJobState.source?.databaseId,
|
||||
sourceContainerName: copyJobState.source?.containerId,
|
||||
targetDatabaseName: copyJobState.target?.databaseId,
|
||||
targetContainerName: copyJobState.target?.containerId,
|
||||
},
|
||||
];
|
||||
const jobName = copyJobState.jobName;
|
||||
|
||||
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
jobName: newValue || "",
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer">
|
||||
<FieldRow label={ContainerCopyMessages.jobNameLabel}>
|
||||
<TextField value={jobName} onChange={onJobNameChange} />
|
||||
</FieldRow>
|
||||
<Stack>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
|
||||
<Text>{copyJobState.source?.subscription?.displayName}</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text>{copyJobState.source?.account?.name}</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DetailsList
|
||||
items={selectedDatabaseAndContainers}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
checkboxVisibility={2}
|
||||
columns={getPreviewCopyJobDetailsListColumns()}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewCopyJob;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IColumn } from "@fluentui/react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
const commonProps = {
|
||||
minWidth: 130,
|
||||
maxWidth: 140,
|
||||
styles: {
|
||||
root: {
|
||||
whiteSpace: "normal",
|
||||
lineHeight: "1.2",
|
||||
wordBreak: "break-word",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getPreviewCopyJobDetailsListColumns = (): IColumn[] => {
|
||||
return [
|
||||
{
|
||||
key: "sourcedbname",
|
||||
name: ContainerCopyMessages.sourceDatabaseLabel,
|
||||
fieldName: "sourceDatabaseName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "sourcecolname",
|
||||
name: ContainerCopyMessages.sourceContainerLabel,
|
||||
fieldName: "sourceContainerName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "targetdbname",
|
||||
name: ContainerCopyMessages.targetDatabaseLabel,
|
||||
fieldName: "targetDatabaseName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "targetcolname",
|
||||
name: ContainerCopyMessages.targetContainerLabel,
|
||||
fieldName: "targetContainerName",
|
||||
...commonProps,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Dropdown } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface AccountDropdownProps {
|
||||
options: DropdownOptionType[];
|
||||
selectedKey?: string;
|
||||
disabled: boolean;
|
||||
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
|
||||
}
|
||||
|
||||
export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
|
||||
({ options, selectedKey, disabled, onChange }) => (
|
||||
<FieldRow label={ContainerCopyMessages.sourceAccountDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.sourceAccountDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.sourceAccountDropdownLabel}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
required
|
||||
selectedKey={selectedKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Checkbox, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
interface MigrationTypeCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
||||
}
|
||||
|
||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
|
||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow">
|
||||
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
|
||||
</Stack>
|
||||
));
|
||||
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Dropdown } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface SubscriptionDropdownProps {
|
||||
options: DropdownOptionType[];
|
||||
selectedKey?: string;
|
||||
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
|
||||
}
|
||||
|
||||
export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.memo(
|
||||
({ options, selectedKey, onChange }) => (
|
||||
<FieldRow label={ContainerCopyMessages.subscriptionDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.subscriptionDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.subscriptionDropdownLabel}
|
||||
options={options}
|
||||
required
|
||||
selectedKey={selectedKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { apiType } from "UserContext";
|
||||
import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels";
|
||||
import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts";
|
||||
import { useSubscriptions } from "../../../../../hooks/useSubscriptions";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
||||
import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils";
|
||||
|
||||
const SelectAccount = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
|
||||
|
||||
const subscriptions: Subscription[] = useSubscriptions();
|
||||
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
|
||||
const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL");
|
||||
|
||||
const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
|
||||
const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
|
||||
|
||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
||||
|
||||
return (
|
||||
<Stack className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
||||
<span>{ContainerCopyMessages.selectAccountDescription}</span>
|
||||
|
||||
<SubscriptionDropdown
|
||||
options={subscriptionOptions}
|
||||
selectedKey={selectedSubscriptionId}
|
||||
onChange={(_ev, option) => handleSelectSourceAccount("subscription", option?.data)}
|
||||
/>
|
||||
|
||||
<AccountDropdown
|
||||
options={accountOptions}
|
||||
selectedKey={copyJobState?.source?.account?.id}
|
||||
disabled={!selectedSubscriptionId}
|
||||
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
|
||||
/>
|
||||
|
||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default SelectAccount;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
|
||||
export function useDropdownOptions(
|
||||
subscriptions: Subscription[],
|
||||
accounts: DatabaseAccount[],
|
||||
): {
|
||||
subscriptionOptions: DropdownOptionType[];
|
||||
accountOptions: DropdownOptionType[];
|
||||
} {
|
||||
const subscriptionOptions = React.useMemo(
|
||||
() =>
|
||||
subscriptions?.map((sub) => ({
|
||||
key: sub.subscriptionId,
|
||||
text: sub.displayName,
|
||||
data: sub,
|
||||
})) || [],
|
||||
[subscriptions],
|
||||
);
|
||||
|
||||
const accountOptions = React.useMemo(
|
||||
() =>
|
||||
accounts?.map((account) => ({
|
||||
key: account.id,
|
||||
text: account.name,
|
||||
data: account,
|
||||
})) || [],
|
||||
[accounts],
|
||||
);
|
||||
|
||||
return { subscriptionOptions, accountOptions };
|
||||
}
|
||||
|
||||
type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
|
||||
|
||||
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
|
||||
const handleSelectSourceAccount = React.useCallback(
|
||||
(type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => {
|
||||
if (type === "subscription") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
subscription: data || null,
|
||||
account: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "account") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
account: data || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
},
|
||||
[setCopyJobState],
|
||||
);
|
||||
|
||||
const handleMigrationTypeChange = React.useCallback(
|
||||
(_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
},
|
||||
[setCopyJobState],
|
||||
);
|
||||
|
||||
return { handleSelectSourceAccount, handleMigrationTypeChange };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
|
||||
export function dropDownChangeHandler(setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>) {
|
||||
return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") =>
|
||||
(_evnt: React.FormEvent, option: DropdownOptionType) => {
|
||||
const value = option.key;
|
||||
setCopyJobState((prevState) => {
|
||||
switch (type) {
|
||||
case "sourceDatabase":
|
||||
return {
|
||||
...prevState,
|
||||
source: { ...prevState.source, databaseId: value, containerId: undefined },
|
||||
};
|
||||
case "sourceContainer":
|
||||
return {
|
||||
...prevState,
|
||||
source: { ...prevState.source, containerId: value },
|
||||
};
|
||||
case "targetDatabase":
|
||||
return {
|
||||
...prevState,
|
||||
target: { ...prevState.target, databaseId: value, containerId: undefined },
|
||||
};
|
||||
case "targetContainer":
|
||||
return {
|
||||
...prevState,
|
||||
target: { ...prevState.target, containerId: value },
|
||||
};
|
||||
default:
|
||||
return prevState;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import { DatabaseModel } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { useDatabases } from "../../../../../hooks/useDatabases";
|
||||
import { useDataContainers } from "../../../../../hooks/useDataContainers";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
|
||||
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
|
||||
import { useMemoizedSourceAndTargetData } from "./memoizedData";
|
||||
|
||||
const SelectSourceAndTargetContainers = () => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
|
||||
useMemoizedSourceAndTargetData(copyJobState);
|
||||
|
||||
const sourceDatabases = useDatabases(...sourceDbParams) || [];
|
||||
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
|
||||
const targetDatabases = useDatabases(...targetDbParams) || [];
|
||||
const targetContainers = useDataContainers(...targetContainerParams) || [];
|
||||
|
||||
const sourceDatabaseOptions = React.useMemo(
|
||||
() => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
||||
[sourceDatabases],
|
||||
);
|
||||
const sourceContainerOptions = React.useMemo(
|
||||
() => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
||||
[sourceContainers],
|
||||
);
|
||||
const targetDatabaseOptions = React.useMemo(
|
||||
() => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
||||
[targetDatabases],
|
||||
);
|
||||
const targetContainerOptions = React.useMemo(
|
||||
() => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
||||
[targetContainers],
|
||||
);
|
||||
|
||||
const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
|
||||
|
||||
return (
|
||||
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
|
||||
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.sourceContainerSubHeading}
|
||||
databaseOptions={sourceDatabaseOptions}
|
||||
selectedDatabase={source?.databaseId}
|
||||
databaseDisabled={false}
|
||||
databaseOnChange={onDropdownChange("sourceDatabase")}
|
||||
containerOptions={sourceContainerOptions}
|
||||
selectedContainer={source?.containerId}
|
||||
containerDisabled={!source?.databaseId}
|
||||
containerOnChange={onDropdownChange("sourceContainer")}
|
||||
/>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.targetContainerSubHeading}
|
||||
databaseOptions={targetDatabaseOptions}
|
||||
selectedDatabase={target?.databaseId}
|
||||
databaseDisabled={false}
|
||||
databaseOnChange={onDropdownChange("targetDatabase")}
|
||||
containerOptions={targetContainerOptions}
|
||||
selectedContainer={target?.containerId}
|
||||
containerDisabled={!target?.databaseId}
|
||||
containerOnChange={onDropdownChange("targetContainer")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectSourceAndTargetContainers;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Dropdown, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
export const DatabaseContainerSection = ({
|
||||
heading,
|
||||
databaseOptions,
|
||||
selectedDatabase,
|
||||
databaseDisabled,
|
||||
databaseOnChange,
|
||||
containerOptions,
|
||||
selectedContainer,
|
||||
containerDisabled,
|
||||
containerOnChange,
|
||||
}: DatabaseContainerSectionProps) => (
|
||||
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
|
||||
<label className="subHeading">{heading}</label>
|
||||
<FieldRow label={ContainerCopyMessages.databaseDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.databaseDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.databaseDropdownLabel}
|
||||
options={databaseOptions}
|
||||
required
|
||||
disabled={!!databaseDisabled}
|
||||
selectedKey={selectedDatabase}
|
||||
onChange={databaseOnChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
|
||||
options={containerOptions}
|
||||
required
|
||||
disabled={!!containerDisabled}
|
||||
selectedKey={selectedContainer}
|
||||
onChange={containerOnChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
|
||||
|
||||
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
|
||||
const { source, target } = copyJobState ?? {};
|
||||
const selectedSourceAccount = source?.account;
|
||||
const selectedTargetAccount = target?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
const {
|
||||
subscriptionId: targetSubscriptionId,
|
||||
resourceGroup: targetResourceGroup,
|
||||
accountName: targetAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
||||
|
||||
const sourceDbParams = React.useMemo(
|
||||
() => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams,
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName],
|
||||
);
|
||||
|
||||
const sourceContainerParams = React.useMemo(
|
||||
() =>
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams,
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId],
|
||||
);
|
||||
|
||||
const targetDbParams = React.useMemo(
|
||||
() => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams,
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName],
|
||||
);
|
||||
|
||||
const targetContainerParams = React.useMemo(
|
||||
() =>
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams,
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId],
|
||||
);
|
||||
|
||||
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useSidePanel } from "../../../../hooks/useSidePanel";
|
||||
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
|
||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
|
||||
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
|
||||
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
|
||||
|
||||
type NavigationState = {
|
||||
screenHistory: string[];
|
||||
};
|
||||
|
||||
type Action = { type: "NEXT"; nextScreen: string } | { type: "PREVIOUS" } | { type: "RESET" };
|
||||
|
||||
function navigationReducer(state: NavigationState, action: Action): NavigationState {
|
||||
switch (action.type) {
|
||||
case "NEXT":
|
||||
return {
|
||||
screenHistory: [...state.screenHistory, action.nextScreen],
|
||||
};
|
||||
case "PREVIOUS":
|
||||
return {
|
||||
screenHistory: state.screenHistory.length > 1 ? state.screenHistory.slice(0, -1) : state.screenHistory,
|
||||
};
|
||||
case "RESET":
|
||||
return {
|
||||
screenHistory: [SCREEN_KEYS.SelectAccount],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyJobNavigation() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { copyJobState, resetCopyJobState } = useCopyJobContext();
|
||||
const screens = useCreateCopyJobScreensList();
|
||||
const { validationCache: cache } = useCopyJobPrerequisitesCache();
|
||||
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
|
||||
|
||||
const currentScreenKey = state.screenHistory[state.screenHistory.length - 1];
|
||||
const currentScreen = screens.find((screen) => screen.key === currentScreenKey);
|
||||
|
||||
const isPrimaryDisabled = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return true;
|
||||
}
|
||||
const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState;
|
||||
return !currentScreen?.validations.every((v) => v.validate(context));
|
||||
}, [currentScreen.key, copyJobState, cache, isLoading]);
|
||||
|
||||
const primaryBtnText = useMemo(() => {
|
||||
if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
||||
return "Copy";
|
||||
}
|
||||
return "Next";
|
||||
}, [currentScreenKey]);
|
||||
|
||||
const isPreviousDisabled = state.screenHistory.length <= 1;
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
resetCopyJobState();
|
||||
useSidePanel.getState().closeSidePanel();
|
||||
}, []);
|
||||
|
||||
const getContainerIdentifiers = (container: typeof copyJobState.source | typeof copyJobState.target) => ({
|
||||
accountId: container?.account?.id || "",
|
||||
databaseId: container?.databaseId || "",
|
||||
containerId: container?.containerId || "",
|
||||
});
|
||||
|
||||
const isSameAccount = (
|
||||
sourceIds: ReturnType<typeof getContainerIdentifiers>,
|
||||
targetIds: ReturnType<typeof getContainerIdentifiers>,
|
||||
) => sourceIds.accountId === targetIds.accountId;
|
||||
|
||||
const areContainersIdentical = () => {
|
||||
const { source, target } = copyJobState;
|
||||
const sourceIds = getContainerIdentifiers(source);
|
||||
const targetIds = getContainerIdentifiers(target);
|
||||
|
||||
return (
|
||||
isSameAccount(sourceIds, targetIds) &&
|
||||
sourceIds.databaseId === targetIds.databaseId &&
|
||||
sourceIds.containerId === targetIds.containerId
|
||||
);
|
||||
};
|
||||
|
||||
const shouldNotShowPermissionScreen = () => {
|
||||
const { source, target, migrationType } = copyJobState;
|
||||
return (
|
||||
migrationType === CopyJobMigrationType.Offline &&
|
||||
isSameAccount(getContainerIdentifiers(source), getContainerIdentifiers(target))
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyJobSubmission = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await submitCreateCopyJob(copyJobState, handleCancel);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message || "Failed to create copy job. Please try again later."
|
||||
: "Failed to create copy job. Please try again later.";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrimary = useCallback(() => {
|
||||
if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) {
|
||||
setError("Source and destination containers cannot be the same. Please select different containers to proceed.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const transitions = {
|
||||
[SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen()
|
||||
? SCREEN_KEYS.SelectSourceAndTargetContainers
|
||||
: SCREEN_KEYS.AssignPermissions,
|
||||
[SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers,
|
||||
[SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob,
|
||||
};
|
||||
|
||||
const nextScreen = transitions[currentScreenKey];
|
||||
if (nextScreen) {
|
||||
dispatch({ type: "NEXT", nextScreen });
|
||||
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
||||
handleCopyJobSubmission();
|
||||
}
|
||||
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
dispatch({ type: "PREVIOUS" });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentScreen,
|
||||
isPrimaryDisabled,
|
||||
isPreviousDisabled,
|
||||
handlePrimary,
|
||||
handlePrevious,
|
||||
handleCancel,
|
||||
primaryBtnText,
|
||||
error,
|
||||
setError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface CopyJobPrerequisitesCacheState {
|
||||
validationCache: Map<string, boolean>;
|
||||
setValidationCache: (cache: Map<string, boolean>) => void;
|
||||
}
|
||||
|
||||
export const useCopyJobPrerequisitesCache = create<CopyJobPrerequisitesCacheState>((set) => ({
|
||||
validationCache: new Map<string, boolean>(),
|
||||
setValidationCache: (cache) => set({ validationCache: cache }),
|
||||
}));
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { CopyJobContextState } from "../../Types/CopyJobTypes";
|
||||
import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions";
|
||||
import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob";
|
||||
import SelectAccount from "../Screens/SelectAccount/SelectAccount";
|
||||
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
|
||||
|
||||
const SCREEN_KEYS = {
|
||||
SelectAccount: "SelectAccount",
|
||||
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
|
||||
PreviewCopyJob: "PreviewCopyJob",
|
||||
AssignPermissions: "AssignPermissions",
|
||||
};
|
||||
|
||||
type Validation = {
|
||||
validate: (state: CopyJobContextState | Map<string, boolean>) => boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Screen = {
|
||||
key: string;
|
||||
component: React.ReactElement;
|
||||
validations: Validation[];
|
||||
};
|
||||
|
||||
function useCreateCopyJobScreensList() {
|
||||
return React.useMemo<Screen[]>(
|
||||
() => [
|
||||
{
|
||||
key: SCREEN_KEYS.SelectAccount,
|
||||
component: <SelectAccount />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account,
|
||||
message: "Please select a subscription and account to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.SelectSourceAndTargetContainers,
|
||||
component: <SelectSourceAndTargetContainers />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) =>
|
||||
!!state?.source?.databaseId &&
|
||||
!!state?.source?.containerId &&
|
||||
!!state?.target?.databaseId &&
|
||||
!!state?.target?.containerId,
|
||||
message: "Please select source and target containers to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.PreviewCopyJob,
|
||||
component: <PreviewCopyJob />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) =>
|
||||
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)),
|
||||
message: "Please enter a job name to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.AssignPermissions,
|
||||
component: <AssignPermissions />,
|
||||
validations: [
|
||||
{
|
||||
validate: (cache: Map<string, boolean>) => {
|
||||
const cacheValuesIterator = Array.from(cache.values());
|
||||
if (cacheValuesIterator.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allValid = cacheValuesIterator.every((isValid: boolean) => isValid);
|
||||
return allValid;
|
||||
},
|
||||
message: "Please ensure all previous steps are valid to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export { SCREEN_KEYS, useCreateCopyJobScreensList };
|
||||
39
src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts
Normal file
39
src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export enum CopyJobMigrationType {
|
||||
Offline = "offline",
|
||||
Online = "online",
|
||||
}
|
||||
|
||||
export enum IdentityType {
|
||||
SystemAssigned = "systemassigned",
|
||||
UserAssigned = "userassigned",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export enum DefaultIdentityType {
|
||||
SystemAssignedIdentity = "systemassignedidentity",
|
||||
}
|
||||
|
||||
export enum BackupPolicyType {
|
||||
Continuous = "Continuous",
|
||||
Periodic = "Periodic",
|
||||
}
|
||||
|
||||
export enum CopyJobStatusType {
|
||||
Pending = "Pending",
|
||||
InProgress = "InProgress",
|
||||
Running = "Running",
|
||||
Partitioning = "Partitioning",
|
||||
Paused = "Paused",
|
||||
Skipped = "Skipped",
|
||||
Completed = "Completed",
|
||||
Cancelled = "Cancelled",
|
||||
Failed = "Failed",
|
||||
Faulted = "Faulted",
|
||||
}
|
||||
|
||||
export enum CopyJobActions {
|
||||
pause = "pause",
|
||||
resume = "resume",
|
||||
cancel = "cancel",
|
||||
complete = "complete",
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
|
||||
interface CopyJobActionMenuProps {
|
||||
job: CopyJobType;
|
||||
handleClick: HandleJobActionClickType;
|
||||
}
|
||||
|
||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
||||
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
|
||||
if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getMenuItems = (): IContextualMenuProps["items"] => {
|
||||
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
|
||||
const updatingAction = updatingJobAction?.action;
|
||||
|
||||
const baseItems = [
|
||||
{
|
||||
key: CopyJobActions.pause,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
||||
iconProps: { iconName: "Pause" },
|
||||
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.cancel,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
||||
iconProps: { iconName: "Cancel" },
|
||||
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.resume,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
||||
iconProps: { iconName: "Play" },
|
||||
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
|
||||
},
|
||||
];
|
||||
|
||||
if (CopyJobStatusType.Paused === job.Status) {
|
||||
return baseItems.filter((item) => item.key !== CopyJobActions.pause);
|
||||
}
|
||||
|
||||
if (CopyJobStatusType.Pending === job.Status) {
|
||||
return baseItems.filter((item) => item.key !== CopyJobActions.resume);
|
||||
}
|
||||
|
||||
if (
|
||||
[CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status)
|
||||
) {
|
||||
const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume);
|
||||
if (job.Mode === CopyJobMigrationType.Online) {
|
||||
filteredItems.push({
|
||||
key: CopyJobActions.complete,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
||||
iconProps: { iconName: "CheckMark" },
|
||||
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
|
||||
});
|
||||
}
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) {
|
||||
return baseItems.filter((item) => item.key === CopyJobActions.resume);
|
||||
}
|
||||
|
||||
return baseItems;
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
role="button"
|
||||
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
menuIconProps={{ iconName: "" }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobActionMenu;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { IColumn } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
||||
|
||||
export const getColumns = (
|
||||
handleSort: (columnKey: string) => void,
|
||||
handleActionClick: HandleJobActionClickType,
|
||||
sortedColumnKey: string | undefined,
|
||||
isSortedDescending: boolean,
|
||||
): IColumn[] => [
|
||||
{
|
||||
key: "LastUpdatedTime",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
|
||||
fieldName: "LastUpdatedTime",
|
||||
minWidth: 140,
|
||||
maxWidth: 300,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "timestamp",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("timestamp"),
|
||||
},
|
||||
{
|
||||
key: "Name",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.name,
|
||||
fieldName: "Name",
|
||||
minWidth: 140,
|
||||
maxWidth: 300,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Name",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("Name"),
|
||||
onRender: (job: CopyJobType) => <span className="jobNameLink">{job.Name}</span>,
|
||||
},
|
||||
{
|
||||
key: "Mode",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
|
||||
fieldName: "Mode",
|
||||
minWidth: 90,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Mode",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("Mode"),
|
||||
},
|
||||
{
|
||||
key: "CompletionPercentage",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
|
||||
fieldName: "CompletionPercentage",
|
||||
minWidth: 110,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "CompletionPercentage",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`,
|
||||
onColumnClick: () => handleSort("CompletionPercentage"),
|
||||
},
|
||||
{
|
||||
key: "CopyJobStatus",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.status,
|
||||
fieldName: "Status",
|
||||
minWidth: 130,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Status",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
|
||||
onColumnClick: () => handleSort("Status"),
|
||||
},
|
||||
{
|
||||
key: "Actions",
|
||||
name: "",
|
||||
minWidth: 80,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
|
||||
import React, { memo } from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
import { CopyJobType } from "../../Types/CopyJobTypes";
|
||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
||||
|
||||
interface CopyJobDetailsProps {
|
||||
job: CopyJobType;
|
||||
}
|
||||
|
||||
const sectionCss = {
|
||||
verticalAlign: { display: "flex", flexDirection: "column" } as React.CSSProperties,
|
||||
headingText: { marginBottom: "10px" } as React.CSSProperties,
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
minWidth: 100,
|
||||
maxWidth: 130,
|
||||
styles: {
|
||||
root: {
|
||||
whiteSpace: "normal",
|
||||
lineHeight: "1.2",
|
||||
wordBreak: "break-word",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getCopyJobDetailsListColumns = (): IColumn[] => {
|
||||
return [
|
||||
{
|
||||
key: "sourcedbcol",
|
||||
name: ContainerCopyMessages.sourceDatabaseLabel,
|
||||
fieldName: "sourceDatabaseName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "sourcecol",
|
||||
name: ContainerCopyMessages.sourceContainerLabel,
|
||||
fieldName: "sourceContainerName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "targetdbcol",
|
||||
name: ContainerCopyMessages.targetDatabaseLabel,
|
||||
fieldName: "targetDatabaseName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "targetcol",
|
||||
name: ContainerCopyMessages.targetContainerLabel,
|
||||
fieldName: "targetContainerName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "statuscol",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.status,
|
||||
fieldName: "jobStatus",
|
||||
onRender: ({ jobStatus }: { jobStatus: CopyJobStatusType }) => <CopyJobStatusWithIcon status={jobStatus} />,
|
||||
...commonProps,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
|
||||
const selectedContainers = [
|
||||
{
|
||||
sourceContainerName: job?.Source?.containerName || "N/A",
|
||||
sourceDatabaseName: job?.Source?.databaseName || "N/A",
|
||||
targetContainerName: job?.Destination?.containerName || "N/A",
|
||||
targetDatabaseName: job?.Destination?.databaseName || "N/A",
|
||||
jobStatus: job?.Status || "",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack className="copyJobDetailsContainer" tokens={{ childrenGap: 15 }} data-testid="copy-job-details">
|
||||
{job.Error ? (
|
||||
<Stack.Item data-testid="error-stack" style={sectionCss.verticalAlign}>
|
||||
<Text className="bold" style={sectionCss.headingText}>
|
||||
{ContainerCopyMessages.errorTitle}
|
||||
</Text>
|
||||
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{job.Error.message}
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
) : null}
|
||||
<Stack.Item data-testid="selectedcollection-stack">
|
||||
<Stack tokens={{ childrenGap: 15 }}>
|
||||
<Stack.Item style={sectionCss.verticalAlign}>
|
||||
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
|
||||
<Text>{job.LastUpdatedTime}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item style={sectionCss.verticalAlign}>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text>{job.Source?.remoteAccountName}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item style={sectionCss.verticalAlign}>
|
||||
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
|
||||
<Text>{job.Mode}</Text>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
<Stack.Item style={sectionCss.verticalAlign}>
|
||||
<DetailsList
|
||||
items={selectedContainers}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
checkboxVisibility={2}
|
||||
columns={getCopyJobDetailsListColumns()}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CopyJobDetails, (prevProps, nextProps) => {
|
||||
return prevProps.job.ID === nextProps.job.ID && prevProps.job.Error === nextProps.job.Error;
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
|
||||
const theme = getTheme();
|
||||
|
||||
const iconClass = mergeStyles({
|
||||
fontSize: "16px",
|
||||
marginRight: "8px",
|
||||
});
|
||||
|
||||
const classNames = mergeStyleSets({
|
||||
[CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
[CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
[CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
[CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
|
||||
[CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
|
||||
[CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
|
||||
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
});
|
||||
|
||||
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
|
||||
[CopyJobStatusType.Pending]: "Clock",
|
||||
[CopyJobStatusType.Paused]: "CirclePause",
|
||||
[CopyJobStatusType.Skipped]: "StatusCircleBlock2",
|
||||
[CopyJobStatusType.Cancelled]: "StatusErrorFull",
|
||||
[CopyJobStatusType.Failed]: "StatusErrorFull",
|
||||
[CopyJobStatusType.Faulted]: "StatusErrorFull",
|
||||
[CopyJobStatusType.Completed]: "CompletedSolid",
|
||||
};
|
||||
|
||||
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
|
||||
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
|
||||
|
||||
const isSpinnerStatus = [
|
||||
CopyJobStatusType.Running,
|
||||
CopyJobStatusType.InProgress,
|
||||
CopyJobStatusType.Partitioning,
|
||||
].includes(status);
|
||||
|
||||
return (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
{isSpinnerStatus ? (
|
||||
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
|
||||
) : (
|
||||
<FontIcon
|
||||
aria-label={status}
|
||||
iconName={iconMap[status] || "UnknownSolid"}
|
||||
className={classNames[status] || classNames.unknown}
|
||||
/>
|
||||
)}
|
||||
<Text>{statusText}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobStatusWithIcon;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ActionButton, Image } from "@fluentui/react";
|
||||
import React, { useCallback } from "react";
|
||||
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
|
||||
import * as Actions from "../../Actions/CopyJobActions";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
|
||||
interface CopyJobsNotFoundProps {}
|
||||
|
||||
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = () => {
|
||||
const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []);
|
||||
return (
|
||||
<div className="notFoundContainer flexContainer centerContent">
|
||||
<Image src={CopyJobIcon} alt={ContainerCopyMessages.noCopyJobsTitle} width={100} height={100} />
|
||||
<h4 className="noCopyJobsMessage">{ContainerCopyMessages.noCopyJobsTitle}</h4>
|
||||
<ActionButton allowDisabledFocus className="createCopyJobButton" onClick={handleCreateCopyJob}>
|
||||
{ContainerCopyMessages.createCopyJobButtonText}
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobsNotFound;
|
||||
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
ConstrainMode,
|
||||
DetailsListLayoutMode,
|
||||
DetailsRow,
|
||||
IColumn,
|
||||
ScrollablePane,
|
||||
ScrollbarVisibility,
|
||||
ShimmeredDetailsList,
|
||||
Stack,
|
||||
Sticky,
|
||||
StickyPositionType,
|
||||
} from "@fluentui/react";
|
||||
import React, { useEffect } from "react";
|
||||
import Pager from "../../../../Common/Pager";
|
||||
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import { getColumns } from "./CopyJobColumns";
|
||||
|
||||
interface CopyJobsListProps {
|
||||
jobs: CopyJobType[];
|
||||
handleActionClick: HandleJobActionClickType;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const [startIndex, setStartIndex] = React.useState(0);
|
||||
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
|
||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
||||
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSortedJobs(jobs);
|
||||
setStartIndex(0);
|
||||
}, [jobs]);
|
||||
|
||||
const handleSort = (columnKey: string) => {
|
||||
const isDescending = sortedColumnKey === columnKey ? !isSortedDescending : false;
|
||||
const sorted = [...sortedJobs].sort((current: any, next: any) => {
|
||||
if (current[columnKey] < next[columnKey]) {
|
||||
return isDescending ? 1 : -1;
|
||||
}
|
||||
if (current[columnKey] > next[columnKey]) {
|
||||
return isDescending ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setSortedJobs(sorted);
|
||||
setSortedColumnKey(columnKey);
|
||||
setIsSortedDescending(isDescending);
|
||||
setStartIndex(0);
|
||||
};
|
||||
|
||||
const columns: IColumn[] = React.useMemo(
|
||||
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
|
||||
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
|
||||
);
|
||||
|
||||
const _handleRowClick = React.useCallback((job: CopyJobType) => {
|
||||
openCopyJobDetailsPanel(job);
|
||||
}, []);
|
||||
|
||||
const _onRenderRow = React.useCallback((props: any) => {
|
||||
return (
|
||||
<div onClick={_handleRowClick.bind(null, props.item)}>
|
||||
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Stack verticalFill={true}>
|
||||
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
|
||||
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
|
||||
<ShimmeredDetailsList
|
||||
onRenderRow={_onRenderRow}
|
||||
checkboxVisibility={2}
|
||||
columns={columns}
|
||||
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
|
||||
enableShimmer={false}
|
||||
constrainMode={ConstrainMode.unconstrained}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
onRenderDetailsHeader={(props, defaultRender) => (
|
||||
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
|
||||
{defaultRender({ ...props })}
|
||||
</Sticky>
|
||||
)}
|
||||
/>
|
||||
</ScrollablePane>
|
||||
</Stack.Item>
|
||||
{sortedJobs.length > pageSize && (
|
||||
<Stack.Item>
|
||||
<Pager
|
||||
disabled={false}
|
||||
startIndex={startIndex}
|
||||
totalCount={sortedJobs.length}
|
||||
pageSize={pageSize}
|
||||
onLoadPage={(startIdx /* pageSize */) => {
|
||||
setStartIndex(startIdx);
|
||||
}}
|
||||
showFirstLast={true}
|
||||
showItemCount={true}
|
||||
/>
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobsList;
|
||||
@@ -0,0 +1,12 @@
|
||||
import create from "zustand";
|
||||
import { MonitorCopyJobsRef } from "./MonitorCopyJobs";
|
||||
|
||||
type MonitorCopyJobsRefStateType = {
|
||||
ref: MonitorCopyJobsRef;
|
||||
setRef: (ref: MonitorCopyJobsRef) => void;
|
||||
};
|
||||
|
||||
export const MonitorCopyJobsRefState = create<MonitorCopyJobsRefStateType>((set) => ({
|
||||
ref: null,
|
||||
setRef: (ref) => set({ ref: ref }),
|
||||
}));
|
||||
122
src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
Normal file
122
src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
|
||||
import { convertToCamelCase } from "../CopyJobUtils";
|
||||
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
|
||||
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
|
||||
import CopyJobsList from "./Components/CopyJobsList";
|
||||
|
||||
const FETCH_INTERVAL_MS = 30 * 1000;
|
||||
|
||||
interface MonitorCopyJobsProps {}
|
||||
|
||||
export interface MonitorCopyJobsRef {
|
||||
refreshJobList: () => void;
|
||||
}
|
||||
|
||||
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
|
||||
const isUpdatingRef = React.useRef(false);
|
||||
const isFirstFetchRef = React.useRef(true);
|
||||
|
||||
const indentLevels = React.useMemo<IndentLevel[]>(() => Array(7).fill({ level: 0, width: "100%" }), []);
|
||||
|
||||
const fetchJobs = React.useCallback(async () => {
|
||||
if (isUpdatingRef.current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isFirstFetchRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await getCopyJobs();
|
||||
setJobs((prevJobs) => {
|
||||
const isSame = JSON.stringify(prevJobs) === JSON.stringify(response);
|
||||
return isSame ? prevJobs : response;
|
||||
});
|
||||
} catch (error) {
|
||||
setError(error.message || "Failed to load copy jobs. Please try again later.");
|
||||
} finally {
|
||||
if (isFirstFetchRef.current) {
|
||||
setLoading(false);
|
||||
isFirstFetchRef.current = false;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchJobs]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refreshJobList: () => {
|
||||
if (isUpdatingRef.current) {
|
||||
setError("Please wait for the current update to complete before refreshing.");
|
||||
return;
|
||||
}
|
||||
fetchJobs();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleActionClick = React.useCallback(
|
||||
async (job: CopyJobType, action: string, setUpdatingJobAction: JobActionUpdatorType) => {
|
||||
try {
|
||||
isUpdatingRef.current = true;
|
||||
setUpdatingJobAction({ jobName: job.Name, action });
|
||||
const updatedCopyJob = await updateCopyJobStatus(job, action);
|
||||
if (updatedCopyJob) {
|
||||
setJobs((prevJobs) =>
|
||||
prevJobs.map((prevJob) =>
|
||||
prevJob.Name === updatedCopyJob.properties.jobName
|
||||
? {
|
||||
...prevJob,
|
||||
Status: convertToCamelCase(updatedCopyJob.properties.status) as CopyJobStatusType,
|
||||
}
|
||||
: prevJob,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.message || "Failed to update copy job status. Please try again later.");
|
||||
} finally {
|
||||
isUpdatingRef.current = false;
|
||||
setUpdatingJobAction(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const memoizedJobsList = React.useMemo(() => {
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (jobs.length > 0) {
|
||||
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
|
||||
}
|
||||
return <CopyJobsNotFound />;
|
||||
}, [jobs, loading, handleActionClick]);
|
||||
|
||||
return (
|
||||
<Stack className="monitorCopyJobs flexContainer">
|
||||
{loading && <ShimmerTree indentLevels={indentLevels} style={{ width: "100%", padding: "1rem 2.5rem" }} />}
|
||||
{error && (
|
||||
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</MessageBar>
|
||||
)}
|
||||
{memoizedJobsList}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default MonitorCopyJobs;
|
||||
143
src/Explorer/ContainerCopy/Types/CopyJobTypes.ts
Normal file
143
src/Explorer/ContainerCopy/Types/CopyJobTypes.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { DatabaseAccount, Subscription } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { ApiType } from "UserContext";
|
||||
import { CosmosSqlDataTransferDataSourceSink } from "../../../Utils/arm/generatedClients/dataTransferService/types";
|
||||
import Explorer from "../../Explorer";
|
||||
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
|
||||
export interface ContainerCopyProps {
|
||||
container: Explorer;
|
||||
}
|
||||
|
||||
export type CopyJobCommandBarBtnType = {
|
||||
key: string;
|
||||
iconSrc: string;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export type CopyJobTabForwardRefHandle = {
|
||||
validate: (state: CopyJobContextState) => boolean;
|
||||
};
|
||||
|
||||
export type DropdownOptionType = {
|
||||
key: string;
|
||||
text: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type DatabaseParams = [string | undefined, string | undefined, string | undefined, ApiType];
|
||||
export type DataContainerParams = [
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
ApiType,
|
||||
];
|
||||
|
||||
export interface DatabaseContainerSectionProps {
|
||||
heading: string;
|
||||
databaseOptions: DropdownOptionType[];
|
||||
selectedDatabase: string;
|
||||
databaseDisabled?: boolean;
|
||||
databaseOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
||||
containerOptions: DropdownOptionType[];
|
||||
selectedContainer: string;
|
||||
containerDisabled?: boolean;
|
||||
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
||||
}
|
||||
|
||||
export interface CopyJobContextState {
|
||||
jobName: string;
|
||||
migrationType: CopyJobMigrationType;
|
||||
sourceReadAccessFromTarget?: boolean;
|
||||
source: {
|
||||
subscription: Subscription;
|
||||
account: DatabaseAccount;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
};
|
||||
target: {
|
||||
subscriptionId: string;
|
||||
account: DatabaseAccount;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CopyJobFlowType {
|
||||
currentScreen: string;
|
||||
}
|
||||
|
||||
export interface CopyJobContextProviderType {
|
||||
flow: CopyJobFlowType;
|
||||
setFlow: React.Dispatch<React.SetStateAction<CopyJobFlowType>>;
|
||||
copyJobState: CopyJobContextState | null;
|
||||
setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>;
|
||||
resetCopyJobState: () => void;
|
||||
}
|
||||
|
||||
export type CopyJobType = {
|
||||
ID: string;
|
||||
Mode: string;
|
||||
Name: string;
|
||||
Status: CopyJobStatusType;
|
||||
CompletionPercentage: number;
|
||||
Duration: string;
|
||||
LastUpdatedTime: string;
|
||||
timestamp: number;
|
||||
Error?: CopyJobErrorType;
|
||||
Source: CosmosSqlDataTransferDataSourceSink;
|
||||
Destination: CosmosSqlDataTransferDataSourceSink;
|
||||
};
|
||||
|
||||
export interface CopyJobErrorType {
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CopyJobError {
|
||||
message: string;
|
||||
navigateToStep?: number;
|
||||
}
|
||||
|
||||
export type DataTransferJobType = {
|
||||
id: string;
|
||||
type: string;
|
||||
properties: {
|
||||
jobName: string;
|
||||
status: string;
|
||||
lastUpdatedUtcTime: string;
|
||||
processedCount: number;
|
||||
totalCount: number;
|
||||
mode: string;
|
||||
duration: string;
|
||||
source: {
|
||||
databaseName: string;
|
||||
collectionName: string;
|
||||
component: string;
|
||||
};
|
||||
destination: {
|
||||
databaseName: string;
|
||||
collectionName: string;
|
||||
component: string;
|
||||
};
|
||||
error: {
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type JobActionUpdatorType = React.Dispatch<React.SetStateAction<{ jobName: string; action: string } | null>>;
|
||||
|
||||
export type HandleJobActionClickType = (
|
||||
job: CopyJobType,
|
||||
action: string,
|
||||
setUpdatingJobAction: JobActionUpdatorType,
|
||||
) => void;
|
||||
|
||||
export type AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => boolean;
|
||||
160
src/Explorer/ContainerCopy/containerCopyStyles.less
Normal file
160
src/Explorer/ContainerCopy/containerCopyStyles.less
Normal file
@@ -0,0 +1,160 @@
|
||||
@import "../../../less/Common/Constants.less";
|
||||
|
||||
#containerCopyWrapper {
|
||||
.centerContent {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.notFoundContainer {
|
||||
.noCopyJobsMessage {
|
||||
font-weight: 600;
|
||||
margin: 0 auto;
|
||||
color: @FocusColor;
|
||||
}
|
||||
button.createCopyJobButton {
|
||||
color: @LinkColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
.createCopyJobScreensContainer {
|
||||
height: 100%;
|
||||
padding: 1em 1.5em;
|
||||
|
||||
label {
|
||||
padding: 0;
|
||||
}
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
label.field-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.flex-fixed-width {
|
||||
flex: 0 0 auto;
|
||||
width: 150px;
|
||||
}
|
||||
.flex-grow-col {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
.databaseContainerSection {
|
||||
label.subHeading {
|
||||
font: inherit;
|
||||
padding: unset;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.accordionHeader {
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.accordionHeaderText {
|
||||
margin-left: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.statusIcon {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
.popover-container {
|
||||
button[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.foreground {
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
transform: translate(0%, -9%);
|
||||
position: absolute;
|
||||
}
|
||||
.createCopyJobErrorMessageBar {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.monitorCopyJobs {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
.ms-DetailsList {
|
||||
width: 100%;
|
||||
|
||||
.ms-DetailsHeader {
|
||||
.ms-DetailsHeader-cell {
|
||||
padding: @DefaultSpace 20px;
|
||||
font-weight: 600;
|
||||
font-size: @DefaultFontSize;
|
||||
color: @BaseHigh;
|
||||
background-color: @BaseLow;
|
||||
border-bottom: @ButtonBorderWidth solid @BaseMedium;
|
||||
|
||||
&:hover {
|
||||
background-color: @BaseMediumLow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ms-DetailsRow {
|
||||
border-bottom: @ButtonBorderWidth solid @BaseMedium;
|
||||
|
||||
&:hover {
|
||||
background-color: @BaseMediumLow;
|
||||
}
|
||||
|
||||
.ms-DetailsRow-cell {
|
||||
padding: @MediumSpace 20px;
|
||||
font-size: @DefaultFontSize;
|
||||
color: @BaseHigh;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.jobNameLink {
|
||||
color: @LinkColor;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button[role="button"] {
|
||||
&.ms-Button--icon {
|
||||
i.ms-Icon {
|
||||
font-size: @LargeSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyJobDetailsContainer {
|
||||
padding: 1em 0 0 2em;
|
||||
|
||||
.ms-DetailsList {
|
||||
width: 100%;
|
||||
.ms-DetailsHeader-cellTitle, .ms-DetailsRow-cell {
|
||||
padding-left: 0;
|
||||
}
|
||||
.ms-DetailsRow-cell {
|
||||
font-size: @DefaultFontSize;
|
||||
color: @BaseHigh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
Reference in New Issue
Block a user