mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-29 22:02:01 +00:00
Added monitor copy job list screen
This commit is contained in:
@@ -1,8 +1,21 @@
|
||||
import { configContext } from "ConfigContext";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { armRequest } from "../../../Utils/arm/request";
|
||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
||||
import {
|
||||
buildDataTransferJobPath,
|
||||
convertTime,
|
||||
convertToCamelCase,
|
||||
COPY_JOB_API_VERSION,
|
||||
COSMOS_SQL_COMPONENT,
|
||||
extractErrorMessage,
|
||||
formatUTCDateTime
|
||||
} from "../CopyJobUtils";
|
||||
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
|
||||
import { CopyJobContextState } from "../Types";
|
||||
import { CopyJobStatusType } from "../Enums";
|
||||
import { CopyJobContextState, CopyJobError, CopyJobType, DataTransferJobType } from "../Types";
|
||||
|
||||
export const openCreateCopyJobPanel = () => {
|
||||
const sidePanelState = useSidePanel.getState()
|
||||
@@ -10,10 +23,132 @@ export const openCreateCopyJobPanel = () => {
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
||||
<CreateCopyJobScreensProvider />,
|
||||
"600px"
|
||||
"650px"
|
||||
);
|
||||
}
|
||||
|
||||
export const submitCreateCopyJob = (state: CopyJobContextState) => {
|
||||
console.log("Submitting create copy job with state:", state);
|
||||
};
|
||||
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
try {
|
||||
const path = buildDataTransferJobPath({
|
||||
subscriptionId: userContext.subscriptionId,
|
||||
resourceGroup: userContext.databaseAccount?.resourceGroup || "",
|
||||
accountName: userContext.databaseAccount?.name || ""
|
||||
});
|
||||
|
||||
const response: { value: DataTransferJobType[] } = await armRequest({
|
||||
host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion: COPY_JOB_API_VERSION
|
||||
});
|
||||
|
||||
const jobs = response.value || [];
|
||||
if (!Array.isArray(jobs)) {
|
||||
throw new Error("Invalid migration job status response: Expected an array of jobs.");
|
||||
}
|
||||
|
||||
/* 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: DataTransferJobType) =>
|
||||
job.properties?.source?.component === COSMOS_SQL_COMPONENT &&
|
||||
job.properties?.destination?.component === COSMOS_SQL_COMPONENT
|
||||
)
|
||||
.sort((current: DataTransferJobType, next: DataTransferJobType) =>
|
||||
new Date(next.properties.lastUpdatedUtcTime).getTime() - new Date(current.properties.lastUpdatedUtcTime).getTime()
|
||||
)
|
||||
.map((job: DataTransferJobType, index: number) => {
|
||||
const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime);
|
||||
|
||||
return {
|
||||
ID: (index + 1).toString(),
|
||||
Mode: job.properties.mode,
|
||||
Name: job.properties.jobName,
|
||||
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) : null,
|
||||
} as CopyJobType;
|
||||
});
|
||||
return formattedJobs;
|
||||
} catch (error) {
|
||||
const errorContent = JSON.stringify(error.content || error);
|
||||
console.error(`Error fetching copy jobs: ${errorContent}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const submitCreateCopyJob = async (state: CopyJobContextState) => {
|
||||
try {
|
||||
const { source, target, migrationType, jobName } = state;
|
||||
const path = buildDataTransferJobPath({
|
||||
subscriptionId: userContext.subscriptionId,
|
||||
resourceGroup: userContext.databaseAccount?.resourceGroup || "",
|
||||
accountName: userContext.databaseAccount?.name || "",
|
||||
jobName
|
||||
});
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const response: { value: DataTransferJobType } = await armRequest({
|
||||
host: configContext.ARM_ENDPOINT, path, method: "PUT", body, apiVersion: COPY_JOB_API_VERSION
|
||||
});
|
||||
return response.value;
|
||||
} catch (error) {
|
||||
console.error("Error submitting create copy job:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobType> => {
|
||||
try {
|
||||
const path = buildDataTransferJobPath({
|
||||
subscriptionId: userContext.subscriptionId,
|
||||
resourceGroup: userContext.databaseAccount?.resourceGroup || "",
|
||||
accountName: userContext.databaseAccount?.name || "",
|
||||
jobName: job.Name,
|
||||
action: action
|
||||
});
|
||||
|
||||
const response: { value: DataTransferJobType } = await armRequest({
|
||||
host: configContext.ARM_ENDPOINT, path, method: "POST", apiVersion: COPY_JOB_API_VERSION
|
||||
});
|
||||
return response.value;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,5 +82,35 @@ export default {
|
||||
title: "Online copy enabled",
|
||||
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
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",
|
||||
stop: "Stop",
|
||||
cutover: "Cutover",
|
||||
viewDetails: "View Details",
|
||||
},
|
||||
Status: {
|
||||
Pending: "Pending",
|
||||
InProgress: "In Progress",
|
||||
Running: "In Progress",
|
||||
Partitioning: "In Progress",
|
||||
Paused: "Paused",
|
||||
Completed: "Completed",
|
||||
Failed: "Failed",
|
||||
Faulted: "Failed",
|
||||
Skipped: "Canceled",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,81 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobErrorType } from "./Types";
|
||||
|
||||
export const buildResourceLink = (resource: DatabaseAccount): string => {
|
||||
const resourceId = resource.id;
|
||||
// TODO: update "ms.portal.azure.com" based on environment (e.g. for PROD or Fairfax)
|
||||
return `https://ms.portal.azure.com/#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; // Return null for invalid format
|
||||
}
|
||||
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"; // Return "0 seconds" if all parts are zero
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
||||
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
|
||||
import NavigationControls from "./Components/NavigationControls";
|
||||
|
||||
const CreateCopyJobScreens: React.FC = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const {
|
||||
currentScreen,
|
||||
isPrimaryDisabled,
|
||||
@@ -14,7 +12,7 @@ const CreateCopyJobScreens: React.FC = () => {
|
||||
handlePrevious,
|
||||
handleCancel,
|
||||
primaryBtnText
|
||||
} = useCopyJobNavigation(copyJobState);
|
||||
} = useCopyJobNavigation();
|
||||
|
||||
return (
|
||||
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
|
||||
|
||||
@@ -1,31 +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 = function (): IColumn[] {
|
||||
return [
|
||||
{
|
||||
key: 'sourcedbname',
|
||||
name: ContainerCopyMessages.sourceDatabaseLabel,
|
||||
fieldName: 'sourceDatabaseName',
|
||||
minWidth: 100
|
||||
...commonProps
|
||||
},
|
||||
{
|
||||
key: 'sourcecolname',
|
||||
name: ContainerCopyMessages.sourceContainerLabel,
|
||||
fieldName: 'sourceContainerName',
|
||||
minWidth: 100
|
||||
...commonProps
|
||||
},
|
||||
{
|
||||
key: 'targetdbname',
|
||||
name: ContainerCopyMessages.targetDatabaseLabel,
|
||||
fieldName: 'targetDatabaseName',
|
||||
minWidth: 100
|
||||
...commonProps
|
||||
},
|
||||
{
|
||||
key: 'targetcolname',
|
||||
name: ContainerCopyMessages.targetContainerLabel,
|
||||
fieldName: 'targetContainerName',
|
||||
minWidth: 100
|
||||
...commonProps
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import { useSidePanel } from "../../../../hooks/useSidePanel";
|
||||
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
|
||||
import { CopyJobContextState } from "../../Types";
|
||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
||||
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
|
||||
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
|
||||
|
||||
@@ -33,7 +33,8 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyJobNavigation(copyJobState: CopyJobContextState) {
|
||||
export function useCopyJobNavigation() {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const screens = useCreateCopyJobScreensList();
|
||||
const { validationCache: cache } = useCopyJobPrerequisitesCache();
|
||||
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
|
||||
|
||||
@@ -53,7 +53,11 @@ function useCreateCopyJobScreensList() {
|
||||
component: <PreviewCopyJob />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) => !!state?.jobName,
|
||||
validate: (state: CopyJobContextState) => !!(
|
||||
typeof state?.jobName === "string"
|
||||
&& state?.jobName
|
||||
&& /^[a-zA-Z0-9-.]+$/.test(state?.jobName)
|
||||
),
|
||||
message: "Please enter a job name to proceed",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,8 +19,22 @@ export enum BackupPolicyType {
|
||||
Periodic = "Periodic",
|
||||
}
|
||||
|
||||
export enum CopyJobMigrationStatus {
|
||||
Pause = "Pause",
|
||||
Resume = "Resume",
|
||||
Cancel = "Cancel",
|
||||
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",
|
||||
stop = "cancel",
|
||||
resume = "resume",
|
||||
cutover = "complete"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums";
|
||||
import { CopyJobType } from "../../Types";
|
||||
|
||||
interface CopyJobActionMenuProps {
|
||||
job: CopyJobType;
|
||||
handleClick: (job: CopyJobType, action: string) => void;
|
||||
}
|
||||
|
||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
||||
if ([
|
||||
CopyJobStatusType.Completed,
|
||||
CopyJobStatusType.Cancelled
|
||||
].includes(job.Status)) return null;
|
||||
|
||||
const getMenuItems = (): IContextualMenuProps["items"] => {
|
||||
const baseItems = [
|
||||
{
|
||||
key: CopyJobActions.pause,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
||||
onClick: () => handleClick(job, CopyJobActions.pause)
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.stop,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.stop,
|
||||
onClick: () => handleClick(job, CopyJobActions.stop)
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.resume,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
||||
onClick: () => handleClick(job, 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.cutover,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cutover,
|
||||
onClick: () => handleClick(job, CopyJobActions.cutover)
|
||||
});
|
||||
}
|
||||
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" }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobActionMenu;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { IColumn } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobType } from "../../Types";
|
||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
||||
|
||||
export const getColumns = (
|
||||
handleSort: (columnKey: string) => void,
|
||||
handleActionClick: (job: CopyJobType, action: string) => void,
|
||||
sortedColumnKey: string | undefined,
|
||||
isSortedDescending: boolean
|
||||
): IColumn[] => [
|
||||
{
|
||||
key: "LastUpdatedTime",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
|
||||
fieldName: "LastUpdatedTime",
|
||||
minWidth: 100,
|
||||
maxWidth: 150,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "timestamp",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("timestamp"),
|
||||
},
|
||||
{
|
||||
key: "Name",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.name,
|
||||
fieldName: "Name",
|
||||
minWidth: 90,
|
||||
maxWidth: 130,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Name",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("Name"),
|
||||
},
|
||||
{
|
||||
key: "Mode",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
|
||||
fieldName: "Mode",
|
||||
minWidth: 70,
|
||||
maxWidth: 90,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Mode",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("Mode"),
|
||||
},
|
||||
{
|
||||
key: "CompletionPercentage",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
|
||||
fieldName: "CompletionPercentage",
|
||||
minWidth: 120,
|
||||
maxWidth: 130,
|
||||
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: 80,
|
||||
maxWidth: 100,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Status",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
|
||||
onColumnClick: () => handleSort("Status"),
|
||||
},
|
||||
{
|
||||
key: "Actions",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.actions,
|
||||
minWidth: 200,
|
||||
isResizable: true,
|
||||
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FontIcon, mergeStyles, mergeStyleSets, Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums";
|
||||
|
||||
// Styles
|
||||
const iconClass = mergeStyles({
|
||||
fontSize: '1em',
|
||||
marginRight: '0.3em',
|
||||
});
|
||||
const classNames = mergeStyleSets({
|
||||
[CopyJobStatusType.Pending]: [{ color: '#fe7f2d' }, iconClass],
|
||||
[CopyJobStatusType.InProgress]: [{ color: '#ee9b00' }, iconClass],
|
||||
[CopyJobStatusType.Running]: [{ color: '#ee9b00' }, iconClass],
|
||||
[CopyJobStatusType.Partitioning]: [{ color: '#ee9b00' }, iconClass],
|
||||
[CopyJobStatusType.Paused]: [{ color: '#bb3e03' }, iconClass],
|
||||
[CopyJobStatusType.Skipped]: [{ color: '#00bbf9' }, iconClass],
|
||||
[CopyJobStatusType.Cancelled]: [{ color: '#00bbf9' }, iconClass],
|
||||
[CopyJobStatusType.Failed]: [{ color: '#d90429' }, iconClass],
|
||||
[CopyJobStatusType.Faulted]: [{ color: '#d90429' }, iconClass],
|
||||
[CopyJobStatusType.Completed]: [{ color: '#386641' }, iconClass],
|
||||
unknown: [{ color: '#000814' }, iconClass],
|
||||
});
|
||||
|
||||
// Icon Mapping
|
||||
const iconMap: Record<CopyJobStatusType, string> = {
|
||||
[CopyJobStatusType.Pending]: "MSNVideosSolid",
|
||||
[CopyJobStatusType.InProgress]: "SyncStatusSolid",
|
||||
[CopyJobStatusType.Running]: "SyncStatusSolid",
|
||||
[CopyJobStatusType.Partitioning]: "SyncStatusSolid",
|
||||
[CopyJobStatusType.Paused]: "CirclePauseSolid",
|
||||
[CopyJobStatusType.Skipped]: "Blocked2Solid",
|
||||
[CopyJobStatusType.Cancelled]: "Blocked2Solid",
|
||||
[CopyJobStatusType.Failed]: "AlertSolid",
|
||||
[CopyJobStatusType.Faulted]: "AlertSolid",
|
||||
[CopyJobStatusType.Completed]: "CompletedSolid"
|
||||
};
|
||||
|
||||
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<FontIcon
|
||||
aria-label={status}
|
||||
iconName={iconMap[status] || "UnknownSolid"}
|
||||
className={classNames[status] || classNames.unknown}
|
||||
/>
|
||||
<Text>{(ContainerCopyMessages.MonitorJobs.Status as any)[status]}</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default CopyJobStatusWithIcon;
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ConstrainMode,
|
||||
DetailsListLayoutMode,
|
||||
DetailsRow,
|
||||
IColumn,
|
||||
ScrollablePane,
|
||||
ScrollbarVisibility,
|
||||
ShimmeredDetailsList,
|
||||
Stack,
|
||||
Sticky,
|
||||
StickyPositionType
|
||||
} from "@fluentui/react";
|
||||
import React, { useEffect } from "react";
|
||||
import { CopyJobType } from "../../Types";
|
||||
import { getColumns } from "./CopyJobColumns";
|
||||
|
||||
interface CopyJobsListProps {
|
||||
jobs: CopyJobType[];
|
||||
handleActionClick: (job: CopyJobType, action: string) => void,
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { height: 'calc(100vh - 15em)' } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 100; // Number of items per page
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const [startIndex, setStartIndex] = React.useState(0);
|
||||
const [sortedJobs, setSortedJobs] = React.useState(jobs);
|
||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
||||
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSortedJobs(jobs);
|
||||
}, [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);
|
||||
}
|
||||
|
||||
const columns: IColumn[] = React.useMemo(
|
||||
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
|
||||
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending]
|
||||
);
|
||||
|
||||
const _handleRowClick = React.useCallback((job: CopyJobType) => {
|
||||
console.log("Row clicked:", job);
|
||||
}, []);
|
||||
|
||||
const _onRenderRow = React.useCallback((props: any) => {
|
||||
return (
|
||||
<div onClick={_handleRowClick.bind(null, props.item)}>
|
||||
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// const totalCount = jobs.length;
|
||||
|
||||
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>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyJobsList;
|
||||
@@ -1,13 +1,101 @@
|
||||
import React from 'react';
|
||||
import { MessageBar, MessageBarType, Stack } from '@fluentui/react';
|
||||
import ShimmerTree, { IndentLevel } from 'Common/ShimmerTree';
|
||||
import React, { useEffect } from 'react';
|
||||
import { getCopyJobs, updateCopyJobStatus } from '../Actions/CopyJobActions';
|
||||
import { convertToCamelCase } from '../CopyJobUtils';
|
||||
import { CopyJobStatusType } from '../Enums';
|
||||
import CopyJobsNotFound from '../MonitorCopyJobs/Components/CopyJobs.NotFound';
|
||||
import { CopyJobType } from '../Types';
|
||||
import CopyJobsList from './Components/CopyJobsList';
|
||||
|
||||
const FETCH_INTERVAL_MS = 30 * 1000; // Interval time in milliseconds (30 seconds)
|
||||
|
||||
interface MonitorCopyJobsProps { }
|
||||
|
||||
const MonitorCopyJobs: React.FC<MonitorCopyJobsProps> = () => {
|
||||
const [loading, setLoading] = React.useState(true); // Start with loading as true
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
|
||||
const isUpdatingRef = React.useRef(false); // Use ref to track updating state
|
||||
const isFirstFetchRef = React.useRef(true); // Use ref to track the first fetch
|
||||
|
||||
const indentLevels = React.useMemo<IndentLevel[]>(
|
||||
() => Array(7).fill({ level: 0, width: "100%" }),
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchJobs = React.useCallback(async () => {
|
||||
if (isUpdatingRef.current) return; // Skip if an update is in progress
|
||||
try {
|
||||
if (isFirstFetchRef.current) setLoading(true); // Show loading spinner only for the first fetch
|
||||
setError(null);
|
||||
|
||||
const response = await getCopyJobs();
|
||||
setJobs((prevJobs) => {
|
||||
// Only update jobs if they are different
|
||||
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); // Hide loading spinner after the first fetch
|
||||
isFirstFetchRef.current = false; // Mark the first fetch as complete
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
|
||||
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchJobs]);
|
||||
|
||||
const handleActionClick = React.useCallback(async (job: CopyJobType, action: string) => {
|
||||
try {
|
||||
isUpdatingRef.current = true; // Mark as updating
|
||||
const updatedCopyJob = await updateCopyJobStatus(job, action);
|
||||
if (updatedCopyJob) {
|
||||
setJobs((prevJobs) =>
|
||||
prevJobs.map((prevJob) =>
|
||||
prevJob.Name === updatedCopyJob.properties.jobName
|
||||
? {
|
||||
...prevJob,
|
||||
MigrationStatus: 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; // Mark as not updating
|
||||
}
|
||||
}, []);
|
||||
|
||||
const memoizedJobsList = React.useMemo(() => {
|
||||
if (loading) return null;
|
||||
if (jobs.length > 0) return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
|
||||
return <CopyJobsNotFound />;
|
||||
}, [jobs, loading, handleActionClick]);
|
||||
|
||||
return (
|
||||
<div className='monitorCopyJobs flexContainer'>
|
||||
<CopyJobsNotFound />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DatabaseAccount, Subscription } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { ApiType } from "UserContext";
|
||||
import Explorer from "../../Explorer";
|
||||
import { CopyJobMigrationType } from "../Enums";
|
||||
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums";
|
||||
|
||||
export interface ContainerCopyProps {
|
||||
container: Explorer;
|
||||
@@ -58,7 +58,7 @@ export interface DatabaseContainerSectionProps {
|
||||
export interface CopyJobContextState {
|
||||
jobName: string;
|
||||
migrationType: CopyJobMigrationType;
|
||||
sourceReadAccessFromTarget: boolean;
|
||||
sourceReadAccessFromTarget?: boolean;
|
||||
// source details
|
||||
source: {
|
||||
subscription: Subscription;
|
||||
@@ -87,4 +87,54 @@ export interface CopyJobContextProviderType {
|
||||
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
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -75,3 +75,7 @@
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.monitorCopyJobs {
|
||||
padding: 0 2.5rem;
|
||||
}
|
||||
Reference in New Issue
Block a user