Added monitor copy job list screen

This commit is contained in:
Bikram Choudhury
2025-10-23 16:53:18 +05:30
parent c504d97f7c
commit 7b437b62ce
15 changed files with 750 additions and 27 deletions

View File

@@ -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;
}
}

View File

@@ -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",
}
}
}

View File

@@ -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]
}
}

View File

@@ -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">

View File

@@ -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
}
];
};

View File

@@ -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] });

View File

@@ -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",
},
],

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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} />,
},
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;
};
};
};

View File

@@ -75,3 +75,7 @@
position: absolute;
}
}
.monitorCopyJobs {
padding: 0 2.5rem;
}