From 7b437b62ce7884c72f765d433e498bf81efe2e95 Mon Sep 17 00:00:00 2001 From: Bikram Choudhury Date: Thu, 23 Oct 2025 16:53:18 +0530 Subject: [PATCH] Added monitor copy job list screen --- .../ContainerCopy/Actions/CopyJobActions.tsx | 145 +++++++++++++++++- .../ContainerCopy/ContainerCopyMessages.ts | 30 ++++ src/Explorer/ContainerCopy/CopyJobUtils.ts | 76 ++++++++- .../Screens/CreateCopyJobScreens.tsx | 4 +- .../Utils/PreviewCopyJobUtils.ts | 20 ++- .../Utils/useCopyJobNavigation.ts | 5 +- .../Utils/useCreateCopyJobScreensList.tsx | 6 +- src/Explorer/ContainerCopy/Enums/index.ts | 22 ++- .../Components/CopyJobActionMenu.tsx | 83 ++++++++++ .../Components/CopyJobColumns.tsx | 78 ++++++++++ .../Components/CopyJobStatusWithIcon.tsx | 50 ++++++ .../Components/CopyJobsList.tsx | 102 ++++++++++++ .../MonitorCopyJobs/MonitorCopyJobs.tsx | 96 +++++++++++- src/Explorer/ContainerCopy/Types/index.ts | 56 ++++++- .../ContainerCopy/containerCopyStyles.less | 4 + 15 files changed, 750 insertions(+), 27 deletions(-) create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 41fb0cf92..877cd7e82 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -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, , - "600px" + "650px" ); } -export const submitCreateCopyJob = (state: CopyJobContextState) => { - console.log("Submitting create copy job with state:", state); -}; \ No newline at end of file +export const getCopyJobs = async (): Promise => { + 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 => { + 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; + } +} diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 3791e87de..2e66c3c46 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -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", + } } } \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts index 9fb9f52b3..c9bcaddd2 100644 --- a/src/Explorer/ContainerCopy/CopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -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}`; -} \ No newline at end of file +} + +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] + } +} + diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx index 8293d282d..1a26fe526 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx @@ -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 ( diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts index d01ed766f..51e197aed 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts @@ -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 } ]; }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index 538422304..86a43f174 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -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] }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index a9a6027b2..96fcb3b41 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -53,7 +53,11 @@ function useCreateCopyJobScreensList() { component: , 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", }, ], diff --git a/src/Explorer/ContainerCopy/Enums/index.ts b/src/Explorer/ContainerCopy/Enums/index.ts index 9a12634bb..90c464547 100644 --- a/src/Explorer/ContainerCopy/Enums/index.ts +++ b/src/Explorer/ContainerCopy/Enums/index.ts @@ -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" } \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx new file mode 100644 index 000000000..9b3863c61 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -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 = ({ 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 ( + + ); +}; + +export default CopyJobActionMenu; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx new file mode 100644 index 000000000..d9473fadf --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx @@ -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) => , + onColumnClick: () => handleSort("Status"), + }, + { + key: "Actions", + name: ContainerCopyMessages.MonitorJobs.Columns.actions, + minWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => , + }, + ]; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx new file mode 100644 index 000000000..1a62f8d2f --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx @@ -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.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 }) => ( + + + {(ContainerCopyMessages.MonitorJobs.Status as any)[status]} + +); + +export default CopyJobStatusWithIcon; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx new file mode 100644 index 000000000..3cd627c32 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -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 = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => { + const [startIndex, setStartIndex] = React.useState(0); + const [sortedJobs, setSortedJobs] = React.useState(jobs); + const [sortedColumnKey, setSortedColumnKey] = React.useState(undefined); + const [isSortedDescending, setIsSortedDescending] = React.useState(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 ( +
+ +
+ ); + }, []); + + // const totalCount = jobs.length; + + return ( +
+ + + + ( + + {defaultRender({ ...props })} + + )} + /> + + + +
+ ); +} + +export default CopyJobsList; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index 9dce7fe3b..baffd0c1d 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -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 = () => { + const [loading, setLoading] = React.useState(true); // Start with loading as true + const [error, setError] = React.useState(null); + const [jobs, setJobs] = React.useState([]); + 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( + () => 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 ; + return ; + }, [jobs, loading, handleActionClick]); + return ( -
- -
+ + {loading && } + {error && ( + setError(null)} + > + {error} + + )} + {memoizedJobsList} + ); } diff --git a/src/Explorer/ContainerCopy/Types/index.ts b/src/Explorer/ContainerCopy/Types/index.ts index 0cedfb875..3d9cd3ff2 100644 --- a/src/Explorer/ContainerCopy/Types/index.ts +++ b/src/Explorer/ContainerCopy/Types/index.ts @@ -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>; resetCopyJobState: () => void; -} \ No newline at end of file +} + +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; + }; + }; +}; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 3241f00da..fa24ef8b7 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -75,3 +75,7 @@ position: absolute; } } + +.monitorCopyJobs { + padding: 0 2.5rem; +} \ No newline at end of file