From 2417da152d99f9ae60048c57cfea866dd113eeb1 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 5 Nov 2025 22:54:00 +0530 Subject: [PATCH] Container Copy Job implementation for SQL accounts (#2241) * Initial dev for container copy * remove padding from label * Added Copy Job prerequisites screen * Added hooks to evaluate reader role access * added copyjob pre-requsite screen along with it's validations * Added monitor copy job list screen * added copy job list refresh and reset functionality * remove arm token dependency * fetch account details from account id instead of context * Fix lint & typescript checks * show copyjob screen from portal navigation * adding copy job details screen * remove duplicate code & show sql accounts only * ui fixes for list job page * pending icon * copy job details screen ui * reset .vscode/settings.json * Fixed existing UTs * disabling action buttons until it's in progress * fixed formatting * Adding loader on submit button and show job creation errors in the panel itself * updating disabling action menu item logic * added custom pager * fix lint and ts errors * updating file names and removing comments * remove comments * modularize the arom common code * Adding content and removing tooltip * updating job details screen * updating online copy enabled screen * Adding below changes - Don't show permission screen for same account in offline mode - Don't show identity permissions for same account in online mode - Show error message if selected containers are identical - Update abort signal messages * added feedback code from explorer * Add tooltips and long polling - Added tooltips to permission sections - Implemented long polling for PITR and online copy enabled sections - Long polling automatically stops after 15 minutes - After polling ends, a refresh button will be displayed --------- Co-authored-by: nishthaAhujaa --- images/ContainerCopy/copy-jobs.svg | 17 ++ src/Common/Constants.ts | 1 + src/Common/DatabaseAccountUtility.ts | 32 ++- src/Common/Pager/Pager.css | 13 ++ src/Common/Pager/index.tsx | 111 ++++++++++ src/Common/ShimmerTree/ShimmerTree.tsx | 32 +++ src/Contracts/DataModels.ts | 47 ++++ src/Contracts/MessageTypes.ts | 1 + src/Contracts/ViewModels.ts | 1 + .../ContainerCopy/Actions/CopyJobActions.tsx | 205 +++++++++++++++++ .../CommandBar/CopyJobCommandBar.tsx | 31 +++ .../ContainerCopy/CommandBar/Utils.ts | 59 +++++ .../ContainerCopy/ContainerCopyMessages.ts | 154 +++++++++++++ .../ContainerCopy/ContainerCopyPanel.tsx | 23 ++ .../ContainerCopy/Context/CopyJobContext.tsx | 54 +++++ src/Explorer/ContainerCopy/CopyJobUtils.ts | 116 ++++++++++ .../AssignPermissions/AddManagedIdentity.tsx | 56 +++++ .../AddReadPermissionToDefaultIdentity.tsx | 87 ++++++++ .../AssignPermissions/AssignPermissions.tsx | 66 ++++++ .../DefaultManagedIdentity.tsx | 57 +++++ .../AssignPermissions/OnlineCopyEnabled.tsx | 131 +++++++++++ .../AssignPermissions/PointInTimeRestore.tsx | 135 ++++++++++++ .../hooks/useManagedIdentity.tsx | 52 +++++ .../hooks/usePermissionsSection.tsx | 206 ++++++++++++++++++ .../AssignPermissions/hooks/useToggle.tsx | 11 + .../Screens/Components/FieldRow.tsx | 25 +++ .../Screens/Components/InfoTooltip.tsx | 17 ++ .../Screens/Components/NavigationControls.tsx | 28 +++ .../Screens/Components/PopoverContainer.tsx | 67 ++++++ .../Screens/CreateCopyJobScreens.tsx | 51 +++++ .../Screens/CreateCopyJobScreensProvider.tsx | 13 ++ .../Screens/PreviewCopyJob/PreviewCopyJob.tsx | 52 +++++ .../Utils/PreviewCopyJobUtils.ts | 43 ++++ .../Components/AccountDropdown.tsx | 30 +++ .../Components/MigrationTypeCheckbox.tsx | 16 ++ .../Components/SubscriptionDropdown.tsx | 28 +++ .../Screens/SelectAccount/SelectAccount.tsx | 51 +++++ .../Utils/selectAccountUtils.tsx | 78 +++++++ .../Events/DropDownChangeHandler.tsx | 35 +++ .../SelectSourceAndTargetContainers.tsx | 70 ++++++ .../components/DatabaseContainerSection.tsx | 43 ++++ .../memoizedData.tsx | 43 ++++ .../Utils/useCopyJobNavigation.ts | 152 +++++++++++++ .../Utils/useCopyJobPrerequisitesCache.tsx | 11 + .../Utils/useCreateCopyJobScreensList.tsx | 87 ++++++++ .../ContainerCopy/Enums/CopyJobEnums.ts | 39 ++++ .../Components/CopyJobActionMenu.tsx | 89 ++++++++ .../Components/CopyJobColumns.tsx | 80 +++++++ .../Components/CopyJobDetails.tsx | 118 ++++++++++ .../Components/CopyJobStatusWithIcon.tsx | 62 ++++++ .../Components/CopyJobs.NotFound.tsx | 22 ++ .../Components/CopyJobsList.tsx | 119 ++++++++++ .../MonitorCopyJobRefState.tsx | 12 + .../MonitorCopyJobs/MonitorCopyJobs.tsx | 122 +++++++++++ .../ContainerCopy/Types/CopyJobTypes.ts | 143 ++++++++++++ .../ContainerCopy/containerCopyStyles.less | 160 ++++++++++++++ src/Explorer/Explorer.tsx | 8 + .../Panes/PanelContainerComponent.test.tsx | 15 ++ .../Panes/PanelContainerComponent.tsx | 8 +- .../PanelContainerComponent.test.tsx.snap | 39 ++++ src/Main.tsx | 46 ++-- src/Platform/Hosted/extractFeatures.ts | 2 + src/UserContext.ts | 2 +- src/Utils/AuthorizationUtils.test.ts | 1 + src/Utils/CopyJobAuthUtils.ts | 12 + src/Utils/arm/RbacUtils.ts | 115 ++++++++++ src/Utils/arm/armUtils.ts | 15 ++ src/Utils/arm/databaseAccountUtils.ts | 36 +++ .../dataTransferService/dataTransferJobs.ts | 18 +- .../dataTransferService/types.ts | 16 +- src/Utils/arm/identityUtils.ts | 57 +++++ src/Utils/arm/request.ts | 5 + src/hooks/useDataContainers.tsx | 75 +++++++ src/hooks/useDatabaseAccounts.tsx | 22 +- src/hooks/useDatabases.tsx | 65 ++++++ src/hooks/useKnockoutExplorer.ts | 4 + src/hooks/useSidePanel.ts | 4 + src/hooks/useSubscriptions.tsx | 19 +- 78 files changed, 4152 insertions(+), 36 deletions(-) create mode 100644 images/ContainerCopy/copy-jobs.svg create mode 100644 src/Common/Pager/Pager.css create mode 100644 src/Common/Pager/index.tsx create mode 100644 src/Common/ShimmerTree/ShimmerTree.tsx create mode 100644 src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx create mode 100644 src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx create mode 100644 src/Explorer/ContainerCopy/CommandBar/Utils.ts create mode 100644 src/Explorer/ContainerCopy/ContainerCopyMessages.ts create mode 100644 src/Explorer/ContainerCopy/ContainerCopyPanel.tsx create mode 100644 src/Explorer/ContainerCopy/Context/CopyJobContext.tsx create mode 100644 src/Explorer/ContainerCopy/CopyJobUtils.ts create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx create mode 100644 src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts 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/CopyJobDetails.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.tsx create mode 100644 src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx create mode 100644 src/Explorer/ContainerCopy/Types/CopyJobTypes.ts create mode 100644 src/Explorer/ContainerCopy/containerCopyStyles.less create mode 100644 src/Utils/CopyJobAuthUtils.ts create mode 100644 src/Utils/arm/RbacUtils.ts create mode 100644 src/Utils/arm/armUtils.ts create mode 100644 src/Utils/arm/databaseAccountUtils.ts create mode 100644 src/Utils/arm/identityUtils.ts create mode 100644 src/hooks/useDataContainers.tsx create mode 100644 src/hooks/useDatabases.tsx diff --git a/images/ContainerCopy/copy-jobs.svg b/images/ContainerCopy/copy-jobs.svg new file mode 100644 index 000000000..2f691ed76 --- /dev/null +++ b/images/ContainerCopy/copy-jobs.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 531a45f58..74d5ef925 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -93,6 +93,7 @@ export class CapabilityNames { public static readonly EnableDataMasking: string = "EnableDataMasking"; public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking"; public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures"; + public static readonly EnableOnlineCopyFeature: string = "EnableOnlineCopyFeature"; } export enum CapacityMode { diff --git a/src/Common/DatabaseAccountUtility.ts b/src/Common/DatabaseAccountUtility.ts index 50ec0064a..41f8c4b0a 100644 --- a/src/Common/DatabaseAccountUtility.ts +++ b/src/Common/DatabaseAccountUtility.ts @@ -1,7 +1,7 @@ import { TagNames, WorkloadType } from "Common/Constants"; import { Tags } from "Contracts/DataModels"; import { isFabric } from "Platform/Fabric/FabricUtil"; -import { userContext } from "../UserContext"; +import { ApiType, userContext } from "../UserContext"; function isVirtualNetworkFilterEnabled() { return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled; @@ -33,3 +33,33 @@ export function isGlobalSecondaryIndexEnabled(): boolean { !isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews ); } + +export const getDatabaseEndpoint = (apiType: ApiType): string => { + switch (apiType) { + case "Mongo": + return "mongodbDatabases"; + case "Cassandra": + return "cassandraKeyspaces"; + case "Gremlin": + return "gremlinDatabases"; + case "Tables": + return "tables"; + default: + case "SQL": + return "sqlDatabases"; + } +}; + +export const getCollectionEndpoint = (apiType: ApiType): string => { + switch (apiType) { + case "Mongo": + return "collections"; + case "Cassandra": + return "tables"; + case "Gremlin": + return "graphs"; + default: + case "SQL": + return "containers"; + } +}; diff --git a/src/Common/Pager/Pager.css b/src/Common/Pager/Pager.css new file mode 100644 index 000000000..a29b6f50a --- /dev/null +++ b/src/Common/Pager/Pager.css @@ -0,0 +1,13 @@ +.pager-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + gap: 16px; +} + +.pager-container > div { + display: flex; + gap: 8px; + align-items: center; +} diff --git a/src/Common/Pager/index.tsx b/src/Common/Pager/index.tsx new file mode 100644 index 000000000..06ff7f2be --- /dev/null +++ b/src/Common/Pager/index.tsx @@ -0,0 +1,111 @@ +import { IconButton, Text } from "@fluentui/react"; +import * as React from "react"; +import "./Pager.css"; + +export interface PagerProps { + startIndex: number; + totalCount: number; + pageSize: number; + onLoadPage: (startIndex: number, pageSize: number) => void; + disabled?: boolean; + showFirstLast?: boolean; + showItemCount?: boolean; + className?: string; +} + +const iconButtonStyles = { + root: { + backgroundColor: "transparent", + }, + rootHovered: { + backgroundColor: "transparent", + }, + rootPressed: { + backgroundColor: "transparent", + }, + rootDisabled: { + backgroundColor: "transparent", + }, + rootFocused: { + backgroundColor: "transparent", + outline: "none", + }, +}; + +const Pager: React.FC = ({ + startIndex, + totalCount, + pageSize, + onLoadPage, + disabled = false, + showFirstLast = true, + showItemCount = true, + className, +}) => { + // Calculate current page and total pages from startIndex + const currentPage = Math.floor(startIndex / pageSize) + 1; + const totalPages = Math.ceil(totalCount / pageSize); + const endIndex = Math.min(startIndex + pageSize, totalCount); + + const handleFirstPage = () => onLoadPage(0, pageSize); + const handlePreviousPage = () => onLoadPage(startIndex - pageSize, pageSize); + const handleNextPage = () => onLoadPage(startIndex + pageSize, pageSize); + const handleLastPage = () => onLoadPage((totalPages - 1) * pageSize, pageSize); + + if (totalCount === 0) { + return null; + } + + return ( +
+ {showItemCount && ( + + Showing {startIndex + 1} - {endIndex} of {totalCount} items + + )} +
+ {showFirstLast && ( + + )} + + + Page {currentPage} of {totalPages} + + + {showFirstLast && ( + + )} +
+
+ ); +}; + +export default Pager; diff --git a/src/Common/ShimmerTree/ShimmerTree.tsx b/src/Common/ShimmerTree/ShimmerTree.tsx new file mode 100644 index 000000000..ee2ddc32a --- /dev/null +++ b/src/Common/ShimmerTree/ShimmerTree.tsx @@ -0,0 +1,32 @@ +import { Shimmer, ShimmerElementType, Stack } from "@fluentui/react"; +import * as React from "react"; + +export interface IndentLevel { + level: number; + width?: string; +} +interface ShimmerTreeProps { + indentLevels: IndentLevel[]; + style?: React.CSSProperties; +} + +const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => { + const renderShimmers = (indent: IndentLevel) => ( + + ); + + return ( + + {indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))} + + ); +}; + +export default ShimmerTree; diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 6f73168f6..0c02ae5fa 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -10,15 +10,42 @@ export interface ArmEntity { resourceGroup?: string; } +export interface DatabaseAccountUserAssignedIdentity { + [key: string]: { + principalId: string; + clientId: string; + }; +} + +export interface DatabaseAccountIdentity { + type: string; + principalId?: string; + tenantId?: string; + userAssignedIdentities?: DatabaseAccountUserAssignedIdentity; +} + export interface DatabaseAccount extends ArmEntity { properties: DatabaseAccountExtendedProperties; systemData?: DatabaseAccountSystemData; + identity?: DatabaseAccountIdentity | null; } export interface DatabaseAccountSystemData { createdAt: string; } +export interface DatabaseAccountBackupPolicy { + type: string; + /* periodicModeProperties?: { + backupIntervalInMinutes: number; + backupRetentionIntervalInHours: number; + backupStorageRedundancy: string; + }; + continuousModeProperties?: { + tier: string; + }; */ +} + export interface DatabaseAccountExtendedProperties { documentEndpoint?: string; disableLocalAuth?: boolean; @@ -29,6 +56,8 @@ export interface DatabaseAccountExtendedProperties { capabilities?: Capability[]; enableMultipleWriteLocations?: boolean; mongoEndpoint?: string; + backupPolicy?: DatabaseAccountBackupPolicy; + defaultIdentity?: string; readLocations?: DatabaseAccountResponseLocation[]; writeLocations?: DatabaseAccountResponseLocation[]; enableFreeTier?: boolean; @@ -101,6 +130,24 @@ export interface Subscription { authorizationSource?: string; } +export interface DatabaseModel extends ArmEntity { + properties: DatabaseGetProperties; +} + +export interface DatabaseGetProperties { + resource: DatabaseResource & ExtendedResourceProperties; +} +export interface DatabaseResource { + id: string; +} + +export interface ExtendedResourceProperties { + readonly _rid?: string; + readonly _self?: string; + readonly _ts?: number; + readonly _etag?: string; +} + export interface SubscriptionPolicies { locationPlacementId: string; quotaId: string; diff --git a/src/Contracts/MessageTypes.ts b/src/Contracts/MessageTypes.ts index 1dd30bb89..658499525 100644 --- a/src/Contracts/MessageTypes.ts +++ b/src/Contracts/MessageTypes.ts @@ -49,4 +49,5 @@ export enum MessageTypes { Ready, // unused. Can be removed if the portal uses the same list of enums. OpenCESCVAFeedbackBlade, ActivateTab, + OpenContainerCopyFeedbackBlade, } diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index a8f6e0c9c..1ea0f2075 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -445,6 +445,7 @@ export interface DataExplorerInputsFrame { }; feedbackPolicies?: any; aadToken?: string; + containerCopyEnabled?: boolean; } export interface SelfServeFrameInputs { diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx new file mode 100644 index 000000000..6394359db --- /dev/null +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -0,0 +1,205 @@ +import React from "react"; +import { userContext } from "UserContext"; +import { useSidePanel } from "../../../hooks/useSidePanel"; +import { + cancel, + complete, + create, + listByDatabaseAccount, + pause, + resume, +} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; +import { + CreateJobRequest, + DataTransferJobGetResults, +} from "../../../Utils/arm/generatedClients/dataTransferService/types"; +import ContainerCopyMessages from "../ContainerCopyMessages"; +import { + convertTime, + convertToCamelCase, + COSMOS_SQL_COMPONENT, + extractErrorMessage, + formatUTCDateTime, + getAccountDetailsFromResourceId, +} from "../CopyJobUtils"; +import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider"; +import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums"; +import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails"; +import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; +import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes"; + +export const openCreateCopyJobPanel = () => { + const sidePanelState = useSidePanel.getState(); + sidePanelState.setPanelHasConsole(false); + sidePanelState.openSidePanel( + ContainerCopyMessages.createCopyJobPanelTitle, + , + "650px", + ); +}; + +export const openCopyJobDetailsPanel = (job: CopyJobType) => { + const sidePanelState = useSidePanel.getState(); + sidePanelState.setPanelHasConsole(false); + sidePanelState.openSidePanel( + ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name), + , + "650px", + ); +}; + +let copyJobsAbortController: AbortController | null = null; + +export const getCopyJobs = async (): Promise => { + try { + if (copyJobsAbortController) { + copyJobsAbortController.abort(); + } + copyJobsAbortController = new AbortController(); + + const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId( + userContext.databaseAccount?.id || "", + ); + const response = await listByDatabaseAccount( + subscriptionId, + resourceGroup, + accountName, + copyJobsAbortController.signal, + ); + + const jobs = response.value || []; + if (!Array.isArray(jobs)) { + throw new Error("Invalid migration job status response: Expected an array of jobs."); + } + copyJobsAbortController = null; + + /* added a lower bound to "0" and upper bound to "100" */ + const calculateCompletionPercentage = (processed: number, total: number): number => { + if ( + typeof processed !== "number" || + typeof total !== "number" || + !isFinite(processed) || + !isFinite(total) || + total <= 0 + ) { + return 0; + } + + const percentage = Math.round((processed / total) * 100); + return Math.max(0, Math.min(100, percentage)); + }; + + const formattedJobs: CopyJobType[] = jobs + .filter( + (job: DataTransferJobGetResults) => + job.properties?.source?.component === COSMOS_SQL_COMPONENT && + job.properties?.destination?.component === COSMOS_SQL_COMPONENT, + ) + .sort( + (current: DataTransferJobGetResults, next: DataTransferJobGetResults) => + new Date(next.properties.lastUpdatedUtcTime).getTime() - + new Date(current.properties.lastUpdatedUtcTime).getTime(), + ) + .map((job: DataTransferJobGetResults, index: number) => { + const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime); + + return { + ID: (index + 1).toString(), + Mode: job.properties.mode, + Name: job.properties.jobName, + Source: job.properties.source, + Destination: job.properties.destination, + Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"], + CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount), + Duration: convertTime(job.properties.duration), + LastUpdatedTime: dateTimeObj.formattedDateTime, + timestamp: dateTimeObj.timestamp, + Error: job.properties.error ? extractErrorMessage(job.properties.error as unknown as CopyJobErrorType) : null, + } as CopyJobType; + }); + return formattedJobs; + } catch (error) { + const errorContent = JSON.stringify(error.content || error.message || error); + if (errorContent.includes("signal is aborted without reason")) { + throw { + message: + "Please wait for the current fetch request to complete. The previous copy job fetch request was aborted.", + }; + } else { + throw error; + } + } +}; + +export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => { + try { + const { source, target, migrationType, jobName } = state; + const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId( + userContext.databaseAccount?.id || "", + ); + const body = { + properties: { + source: { + component: "CosmosDBSql", + remoteAccountName: source?.account?.name, + databaseName: source?.databaseId, + containerName: source?.containerId, + }, + destination: { + component: "CosmosDBSql", + databaseName: target?.databaseId, + containerName: target?.containerId, + }, + mode: migrationType, + }, + } as unknown as CreateJobRequest; + + const response = await create(subscriptionId, resourceGroup, accountName, jobName, body); + MonitorCopyJobsRefState.getState().ref?.refreshJobList(); + onSuccess(); + return response; + } catch (error) { + console.error("Error submitting create copy job:", error); + throw error; + } +}; + +export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise => { + try { + let updateFn = null; + switch (action.toLowerCase()) { + case CopyJobActions.pause: + updateFn = pause; + break; + case CopyJobActions.resume: + updateFn = resume; + break; + case CopyJobActions.cancel: + updateFn = cancel; + break; + case CopyJobActions.complete: + updateFn = complete; + break; + default: + throw new Error(`Unsupported action: ${action}`); + } + const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId( + userContext.databaseAccount?.id || "", + ); + const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name); + + return response; + } catch (error) { + const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error); + + const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning]; + const pattern = new RegExp(`'(${statusList.join("|")})'`, "g"); + const normalizedErrorMessage = errorMessage.replace( + pattern, + `'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`, + ); + + console.error(`Error updating copy job status: ${normalizedErrorMessage}`); + throw error; + } +}; diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx new file mode 100644 index 000000000..f7d981484 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -0,0 +1,31 @@ +import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; +import React from "react"; +import { StyleConstants } from "../../../Common/StyleConstants"; +import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; +import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil"; +import { ContainerCopyProps } from "../Types/CopyJobTypes"; +import { getCommandBarButtons } from "./Utils"; + +const backgroundColor = StyleConstants.BaseLight; +const rootStyle = { + root: { + backgroundColor: backgroundColor, + }, +}; + +const CopyJobCommandBar: React.FC = ({ container }) => { + const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container); + const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor); + + return ( +
+ +
+ ); +}; + +export default CopyJobCommandBar; diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts new file mode 100644 index 000000000..a4d4b9d8b --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -0,0 +1,59 @@ +import AddIcon from "../../../../images/Add.svg"; +import FeedbackIcon from "../../../../images/Feedback-Command.svg"; +import RefreshIcon from "../../../../images/refresh-cosmos.svg"; +import { configContext, Platform } from "../../../ConfigContext"; +import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; +import Explorer from "../../Explorer"; +import * as Actions from "../Actions/CopyJobActions"; +import ContainerCopyMessages from "../ContainerCopyMessages"; +import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; +import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes"; + +function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] { + const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref); + const buttons: CopyJobCommandBarBtnType[] = [ + { + key: "createCopyJob", + iconSrc: AddIcon, + label: ContainerCopyMessages.createCopyJobButtonLabel, + ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel, + onClick: Actions.openCreateCopyJobPanel, + }, + { + key: "refresh", + iconSrc: RefreshIcon, + label: ContainerCopyMessages.refreshButtonLabel, + ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel, + onClick: () => monitorCopyJobsRef?.refreshJobList(), + }, + ]; + if (configContext.platform === Platform.Portal) { + buttons.push({ + key: "feedback", + iconSrc: FeedbackIcon, + label: ContainerCopyMessages.feedbackButtonLabel, + ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel, + onClick: () => { + container.openContainerCopyFeedbackBlade(); + }, + }); + } + return buttons; +} + +function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps { + return { + iconSrc: config.iconSrc, + iconAlt: config.label, + onCommandClick: config.onClick, + commandButtonLabel: undefined as string | undefined, + ariaLabel: config.ariaLabel, + tooltipText: config.label, + hasPopup: false, + disabled: config.disabled ?? false, + }; +} + +export function getCommandBarButtons(container: Explorer): CommandButtonComponentProps[] { + return getCopyJobBtns(container).map(btnMapper); +} diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts new file mode 100644 index 000000000..80c9c658f --- /dev/null +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -0,0 +1,154 @@ +export default { + // Copy Job Command Bar + feedbackButtonLabel: "Feedback", + feedbackButtonAriaLabel: "Provide feedback on copy jobs", + refreshButtonLabel: "Refresh", + refreshButtonAriaLabel: "Refresh copy jobs", + createCopyJobButtonLabel: "Create Copy Job", + createCopyJobButtonAriaLabel: "Create a new container copy job", + + // No Copy Jobs Found + noCopyJobsTitle: "No copy jobs to show", + createCopyJobButtonText: "Create a container copy job", + + // Copy Job Details + copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details", + errorTitle: "Error Details", + selectedContainers: "Selected Containers", + + // Create Copy Job Panel + createCopyJobPanelTitle: "Create copy job", + + // Select Account Screen + selectAccountDescription: "Please select a source account from which to copy.", + subscriptionDropdownLabel: "Subscription", + subscriptionDropdownPlaceholder: "Select a subscription", + sourceAccountDropdownLabel: "Account", + sourceAccountDropdownPlaceholder: "Select an account", + migrationTypeCheckboxLabel: "Copy container in offline mode", + + // Select Source and Target Containers Screen + selectSourceAndTargetContainersDescription: + "Please select a source container and a destination container to copy to.", + sourceContainerSubHeading: "Source container", + targetContainerSubHeading: "Destination container", + databaseDropdownLabel: "Database", + databaseDropdownPlaceholder: "Select a database", + containerDropdownLabel: "Container", + containerDropdownPlaceholder: "Select a container", + + // Preview and Create Screen + jobNameLabel: "Job name", + sourceSubscriptionLabel: "Source subscription", + sourceAccountLabel: "Source account", + sourceDatabaseLabel: "Source database", + sourceContainerLabel: "Source container", + targetDatabaseLabel: "Destination database", + targetContainerLabel: "Destination container", + + // Assign Permissions Screen + assignPermissions: { + description: + "To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.", + }, + toggleBtn: { + onText: "On", + offText: "Off", + }, + addManagedIdentity: { + title: "System-assigned managed identity enabled.", + description: + "A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.", + descriptionHrefText: "Learn more about Managed identities.", + descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview", + toggleLabel: "System assigned managed identity", + tooltip: { + content: "Learn more about", + hrefText: "Managed Identities.", + href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview", + }, + userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.", + userAssignedIdentityLabel: "You may also select a user assigned managed identity.", + createUserAssignedIdentityLink: "Create User Assigned Managed Identity", + enablementTitle: "Enable system assigned managed identity", + enablementDescription: (accountName: string) => + accountName + ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. ` + : "", + }, + defaultManagedIdentity: { + title: "System-assigned managed identity set as default.", + description: (accountName: string) => + `Set the system-assigned managed identity as default for "${accountName}" by switching it on.`, + tooltip: { + content: "Learn more about", + hrefText: "Default Managed Identities.", + href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview", + }, + popoverTitle: "System assigned managed identity set as default", + popoverDescription: (accountName: string) => + `Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `, + }, + readPermissionAssigned: { + title: "Read permissions assigned to the default identity.", + description: + "To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.", + tooltip: { + content: "Learn more about", + hrefText: "Read permissions.", + href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control", + }, + popoverTitle: "Read permissions assigned to default identity.", + popoverDescription: + "Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ", + }, + pointInTimeRestore: { + title: "Point In Time Restore enabled", + description: (accessName: string) => + `To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`, + tooltip: { + content: "Learn more about", + hrefText: "Continuous Backup", + href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction", + }, + buttonText: "Enable Point In Time Restore", + }, + onlineCopyEnabled: { + title: "Online copy enabled", + description: (accountName: string) => `Use Azure CLI to enable Online copy on "${accountName}".`, + hrefText: "Learn more about online copy jobs", + href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy", + buttonText: "Enable Online Copy", + }, + MonitorJobs: { + Columns: { + lastUpdatedTime: "Date & time", + name: "Job name", + status: "Status", + completionPercentage: "Completion %", + duration: "Duration", + error: "Error message", + mode: "Mode", + actions: "Actions", + }, + Actions: { + pause: "Pause", + resume: "Resume", + cancel: "Cancel", + complete: "Complete", + viewDetails: "View Details", + }, + Status: { + Pending: "Pending", + InProgress: "In Progress", + Running: "In Progress", + Partitioning: "In Progress", + Paused: "Paused", + Completed: "Completed", + Failed: "Failed", + Faulted: "Failed", + Skipped: "Cancelled", + Cancelled: "Cancelled", + }, + }, +}; diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx new file mode 100644 index 000000000..026fb7f09 --- /dev/null +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx @@ -0,0 +1,23 @@ +import React, { useEffect } from "react"; +import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar"; +import "./containerCopyStyles.less"; +import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState"; +import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs"; +import { ContainerCopyProps } from "./Types/CopyJobTypes"; + +const ContainerCopyPanel: React.FC = ({ container }) => { + const monitorCopyJobsRef = React.useRef(); + useEffect(() => { + if (monitorCopyJobsRef.current) { + MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current); + } + }, [monitorCopyJobsRef.current]); + return ( +
+ + +
+ ); +}; + +export default ContainerCopyPanel; diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx new file mode 100644 index 000000000..16f17598b --- /dev/null +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { userContext } from "UserContext"; +import { CopyJobMigrationType } from "../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes"; + +export const CopyJobContext = React.createContext(null); +export const useCopyJobContext = (): CopyJobContextProviderType => { + const context = React.useContext(CopyJobContext); + if (!context) { + throw new Error("useCopyJobContext must be used within a CopyJobContextProvider"); + } + return context; +}; + +interface CopyJobContextProviderProps { + children: React.ReactNode; +} + +const getInitialCopyJobState = (): CopyJobContextState => { + return { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: userContext.subscriptionId || "", + account: userContext.databaseAccount || null, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + }; +}; + +const CopyJobContextProvider: React.FC = (props) => { + const [copyJobState, setCopyJobState] = React.useState(getInitialCopyJobState()); + const [flow, setFlow] = React.useState(null); + + const resetCopyJobState = () => { + setCopyJobState(getInitialCopyJobState()); + }; + + return ( + + {props.children} + + ); +}; + +export default CopyJobContextProvider; diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts new file mode 100644 index 000000000..a37fa660a --- /dev/null +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -0,0 +1,116 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { CopyJobErrorType } from "./Types/CopyJobTypes"; + +const azurePortalMpacEndpoint = "https://ms.portal.azure.com/"; + +export const buildResourceLink = (resource: DatabaseAccount): string => { + const resourceId = resource.id; + let parentOrigin = window.location.ancestorOrigins?.[0] ?? window.location.origin; + + if (/\/\/localhost:/.test(parentOrigin)) { + parentOrigin = azurePortalMpacEndpoint; + } else if (/\/\/cosmos\.azure/.test(parentOrigin)) { + parentOrigin = parentOrigin.replace("cosmos.azure", "portal.azure"); + } + + parentOrigin = parentOrigin.replace(/\/$/, ""); + + return `${parentOrigin}/#resource${resourceId}`; +}; + +export const COSMOS_SQL_COMPONENT = "CosmosDBSql"; + +export const COPY_JOB_API_VERSION = "2025-05-01-preview"; + +export function buildDataTransferJobPath({ + subscriptionId, + resourceGroup, + accountName, + jobName, + action, +}: { + subscriptionId: string; + resourceGroup: string; + accountName: string; + jobName?: string; + action?: string; +}) { + let path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`; + if (jobName) { + path += `/${jobName}`; + } + if (action) { + path += `/${action}`; + } + return path; +} + +export function convertTime(timeStr: string): string | null { + const timeParts = timeStr.split(":").map(Number); + + if (timeParts.length !== 3 || timeParts.some(isNaN)) { + return null; + } + const formatPart = (value: number, unit: string) => { + if (unit === "seconds") { + value = Math.round(value); + } + return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : ""; + }; + + const [hours, minutes, seconds] = timeParts; + const formattedTimeParts = [ + formatPart(hours, "hours"), + formatPart(minutes, "minutes"), + formatPart(seconds, "seconds"), + ] + .filter(Boolean) + .join(", "); + + return formattedTimeParts || "0 seconds"; +} + +export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null { + const date = new Date(utcStr); + if (isNaN(date.getTime())) { + return null; + } + + return { + formattedDateTime: new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + timeStyle: "medium", + timeZone: "UTC", + }).format(date), + timestamp: date.getTime(), + }; +} + +export function convertToCamelCase(str: string): string { + const formattedStr = str + .split(/\s+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); + return formattedStr; +} + +export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType { + return { + ...error, + message: error.message.split("\r\n\r\n")[0], + }; +} + +export function getAccountDetailsFromResourceId(accountId: string | undefined) { + if (!accountId) { + return null; + } + const pattern = new RegExp( + "/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB/databaseAccounts/([^/]+)", + "i", + ); + const matches = accountId.match(pattern); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, subscriptionId, resourceGroup, accountName] = matches || []; + return { subscriptionId, resourceGroup, accountName }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx new file mode 100644 index 000000000..1cff2c213 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx @@ -0,0 +1,56 @@ +import { Link, Stack, Text, Toggle } from "@fluentui/react"; +import React from "react"; +import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import InfoTooltip from "../Components/InfoTooltip"; +import PopoverMessage from "../Components/PopoverContainer"; +import useManagedIdentity from "./hooks/useManagedIdentity"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; +import useToggle from "./hooks/useToggle"; + +const managedIdentityTooltip = ( + + {ContainerCopyMessages.addManagedIdentity.tooltip.content}   + + {ContainerCopyMessages.addManagedIdentity.tooltip.hrefText} + + +); +type AddManagedIdentityProps = Partial; + +const AddManagedIdentity: React.FC = () => { + const { copyJobState } = useCopyJobContext(); + const [systemAssigned, onToggle] = useToggle(false); + const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity); + + return ( + + + {ContainerCopyMessages.addManagedIdentity.description}  + + {ContainerCopyMessages.addManagedIdentity.descriptionHrefText} + {" "} +   + + + + onToggle(null, false)} + onPrimary={handleAddSystemIdentity} + > + {ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)} + + + ); +}; + +export default AddManagedIdentity; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx new file mode 100644 index 000000000..4c3f5e98d --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx @@ -0,0 +1,87 @@ +import { Link, Stack, Text, Toggle } from "@fluentui/react"; +import React, { useCallback } from "react"; +import { assignRole } from "../../../../../Utils/arm/RbacUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; +import InfoTooltip from "../Components/InfoTooltip"; +import PopoverMessage from "../Components/PopoverContainer"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; +import useToggle from "./hooks/useToggle"; + +const TooltipContent = ( + + {ContainerCopyMessages.readPermissionAssigned.tooltip.content}   + + {ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText} + + +); +type AddReadPermissionToDefaultIdentityProps = Partial; + +const AddReadPermissionToDefaultIdentity: React.FC = () => { + const [loading, setLoading] = React.useState(false); + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const [readPermissionAssigned, onToggle] = useToggle(false); + + const handleAddReadPermission = useCallback(async () => { + const { source, target } = copyJobState; + const selectedSourceAccount = source?.account; + try { + const { + subscriptionId: sourceSubscriptionId, + resourceGroup: sourceResourceGroup, + accountName: sourceAccountName, + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + + setLoading(true); + const assignedRole = await assignRole( + sourceSubscriptionId, + sourceResourceGroup, + sourceAccountName, + target?.account?.identity?.principalId ?? "", + ); + if (assignedRole) { + setCopyJobState((prevState) => ({ + ...prevState, + sourceReadAccessFromTarget: true, + })); + } + } catch (error) { + console.error("Error assigning read permission to default identity:", error); + } finally { + setLoading(false); + } + }, [copyJobState, setCopyJobState]); + + return ( + + + {ContainerCopyMessages.readPermissionAssigned.description}  + + + + onToggle(null, false)} + onPrimary={handleAddReadPermission} + > + {ContainerCopyMessages.readPermissionAssigned.popoverDescription} + + + ); +}; + +export default AddReadPermissionToDefaultIdentity; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx new file mode 100644 index 000000000..6a5e69154 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx @@ -0,0 +1,66 @@ +import { Image, Stack, Text } from "@fluentui/react"; +import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components"; +import React, { useEffect } from "react"; +import CheckmarkIcon from "../../../../../../images/successfulPopup.svg"; +import WarningIcon from "../../../../../../images/warning.svg"; +import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection"; + +const PermissionSection: React.FC = ({ id, title, Component, completed, disabled }) => ( + + + + {title} + + {completed + + + + + +); + +const AssignPermissions = () => { + const { copyJobState } = useCopyJobContext(); + const permissionSections = usePermissionSections(copyJobState); + const [openItems, setOpenItems] = React.useState([]); + + const indentLevels = React.useMemo( + () => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }), + [], + ); + + useEffect(() => { + const firstIncompleteSection = permissionSections.find((section) => !section.completed); + const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : []; + if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) { + setOpenItems(nextOpenItems); + } + }, [permissionSections]); + + return ( + + {ContainerCopyMessages.assignPermissions.description} + {permissionSections?.length === 0 ? ( + + ) : ( + + {permissionSections.map((section) => ( + + ))} + + )} + + ); +}; + +export default AssignPermissions; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx new file mode 100644 index 000000000..da6bd4815 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx @@ -0,0 +1,57 @@ +import { Link, Stack, Text, Toggle } from "@fluentui/react"; +import React from "react"; +import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import InfoTooltip from "../Components/InfoTooltip"; +import PopoverMessage from "../Components/PopoverContainer"; +import useManagedIdentity from "./hooks/useManagedIdentity"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; +import useToggle from "./hooks/useToggle"; + +const managedIdentityTooltip = ( + + {ContainerCopyMessages.defaultManagedIdentity.tooltip.content}   + + {ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText} + + +); +type AddManagedIdentityProps = Partial; + +const DefaultManagedIdentity: React.FC = () => { + const { copyJobState } = useCopyJobContext(); + const [defaultSystemAssigned, onToggle] = useToggle(false); + const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity); + + return ( + +
+ {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}   + +
+ + onToggle(null, false)} + onPrimary={handleAddSystemIdentity} + > + {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)} + +
+ ); +}; + +export default DefaultManagedIdentity; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx new file mode 100644 index 000000000..f616df1c6 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -0,0 +1,131 @@ +import { Link, PrimaryButton, Stack } from "@fluentui/react"; +import { DatabaseAccount } from "Contracts/DataModels"; +import React from "react"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; +import { AccountValidatorFn } from "../../../Types/CopyJobTypes"; + +const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => { + const prevCapabilities = prev?.properties?.capabilities ?? []; + const nextCapabilities = next?.properties?.capabilities ?? []; + + return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities); +}; + +const OnlineCopyEnabled: React.FC = () => { + const [loading, setLoading] = React.useState(false); + const [showRefreshButton, setShowRefreshButton] = React.useState(false); + const intervalRef = React.useRef(null); + const timeoutRef = React.useRef(null); + const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); + const selectedSourceAccount = source?.account; + const { + subscriptionId: sourceSubscriptionId, + resourceGroup: sourceResourceGroup, + accountName: sourceAccountName, + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + + const handleFetchAccount = async () => { + try { + const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName); + if (account && validatorFn(selectedSourceAccount, account)) { + setCopyJobState((prevState) => ({ + ...prevState, + source: { ...prevState.source, account: account }, + })); + setLoading(false); + } + } catch (error) { + console.error("Error fetching source account after enabling online copy:", error); + setLoading(false); + } + }; + + const clearIntervalAndShowRefresh = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setShowRefreshButton(true); + }; + + const handleRefresh = () => { + setLoading(true); + handleFetchAccount(); + }; + + React.useEffect(() => { + intervalRef.current = setInterval(() => { + handleFetchAccount(); + }, 30 * 1000); + + timeoutRef.current = setTimeout( + () => { + clearIntervalAndShowRefresh(); + }, + 15 * 60 * 1000, + ); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + return ( + + + {ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}  + + {ContainerCopyMessages.onlineCopyEnabled.hrefText} + + + +
+          
+            {`# Set shell variables
+$resourceGroupName = 
+$accountName = 
+$EnableOnlineContainerCopy = "EnableOnlineContainerCopy"
+
+# List down existing capabilities of your account
+$cosmosdb = az cosmosdb show --resource-group $resourceGroupName --name $accountName
+
+$capabilities = (($cosmosdb | ConvertFrom-Json).capabilities)
+
+# Append EnableOnlineContainerCopy capability in the list of capabilities
+$capabilitiesToAdd = @()
+foreach ($item in $capabilities) {
+  $capabilitiesToAdd += $item.name
+}
+$capabilitiesToAdd += $EnableOnlineContainerCopy
+
+# Update Cosmos DB account
+az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourceGroupName`}
+          
+        
+
+ {showRefreshButton && ( + + + + )} +
+ ); +}; + +export default OnlineCopyEnabled; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx new file mode 100644 index 000000000..eb072d92b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -0,0 +1,135 @@ +import { Link, PrimaryButton, Stack, Text } from "@fluentui/react"; +import { DatabaseAccount } from "Contracts/DataModels"; +import React, { useEffect, useRef, useState } from "react"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; +import { AccountValidatorFn } from "../../../Types/CopyJobTypes"; +import InfoTooltip from "../Components/InfoTooltip"; + +const tooltipContent = ( + + {ContainerCopyMessages.pointInTimeRestore.tooltip.content}   + + {ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText} + + +); + +const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => { + const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? ""; + const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? ""; + + return prevBackupPolicy !== nextBackupPolicy; +}; + +const PointInTimeRestore: React.FC = () => { + const [loading, setLoading] = useState(false); + const [showRefreshButton, setShowRefreshButton] = useState(false); + const intervalRef = useRef(null); + const timeoutRef = useRef(null); + const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); + const sourceAccountLink = buildResourceLink(source?.account); + const featureUrl = `${sourceAccountLink}/backupRestore`; + const selectedSourceAccount = source?.account; + const { + subscriptionId: sourceSubscriptionId, + resourceGroup: sourceResourceGroup, + accountName: sourceAccountName, + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + const handleFetchAccount = async () => { + try { + const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName); + if (account && validatorFn(selectedSourceAccount, account)) { + setCopyJobState((prevState) => ({ + ...prevState, + source: { ...prevState.source, account: account }, + })); + setLoading(false); + } + } catch (error) { + console.error("Error fetching source account after Point-in-Time Restore:", error); + setLoading(false); + } + }; + + const clearIntervalAndShowRefresh = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setLoading(false); + setShowRefreshButton(true); + }; + + const handleRefresh = () => { + setLoading(true); + handleFetchAccount(); + }; + + const openWindowAndMonitor = () => { + setLoading(true); + setShowRefreshButton(false); + window.open(featureUrl, "_blank"); + + intervalRef.current = setInterval(() => { + handleFetchAccount(); + }, 30 * 1000); + + timeoutRef.current = setTimeout( + () => { + clearIntervalAndShowRefresh(); + }, + 15 * 60 * 1000, + ); + }; + + return ( + + + {ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")} + {tooltipContent && ( + <> + {" "} + + + )} + + + {showRefreshButton ? ( + + ) : ( + + )} + + + ); +}; + +export default PointInTimeRestore; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx new file mode 100644 index 000000000..08c79da3b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx @@ -0,0 +1,52 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { useCallback, useState } from "react"; +import { useCopyJobContext } from "../../../../Context/CopyJobContext"; +import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils"; + +interface UseManagedIdentityUpdaterParams { + updateIdentityFn: ( + subscriptionId: string, + resourceGroup?: string, + accountName?: string, + ) => Promise; +} + +interface UseManagedIdentityUpdaterReturn { + loading: boolean; + handleAddSystemIdentity: () => Promise; +} + +const useManagedIdentity = ( + updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"], +): UseManagedIdentityUpdaterReturn => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const [loading, setLoading] = useState(false); + + const handleAddSystemIdentity = useCallback(async (): Promise => { + try { + setLoading(true); + const selectedTargetAccount = copyJobState?.target?.account; + const { + subscriptionId: targetSubscriptionId, + resourceGroup: targetResourceGroup, + accountName: targetAccountName, + } = getAccountDetailsFromResourceId(selectedTargetAccount?.id); + + const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName); + if (updatedAccount) { + setCopyJobState((prevState) => ({ + ...prevState, + target: { ...prevState.target, account: updatedAccount }, + })); + } + } catch (error) { + console.error("Error enabling system-assigned managed identity:", error); + } finally { + setLoading(false); + } + }, [copyJobState, updateIdentityFn, setCopyJobState]); + + return { loading, handleAddSystemIdentity }; +}; + +export default useManagedIdentity; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx new file mode 100644 index 000000000..53db66ff8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx @@ -0,0 +1,206 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { CapabilityNames } from "../../../../../../Common/Constants"; +import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils"; +import { + BackupPolicyType, + CopyJobMigrationType, + DefaultIdentityType, + IdentityType, +} from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../../../Types/CopyJobTypes"; +import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache"; +import AddManagedIdentity from "../AddManagedIdentity"; +import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity"; +import DefaultManagedIdentity from "../DefaultManagedIdentity"; +import OnlineCopyEnabled from "../OnlineCopyEnabled"; +import PointInTimeRestore from "../PointInTimeRestore"; + +export interface PermissionSectionConfig { + id: string; + title: string; + Component: React.ComponentType; + disabled: boolean; + completed?: boolean; + validate?: (state: CopyJobContextState) => boolean | Promise; +} + +export const SECTION_IDS = { + addManagedIdentity: "addManagedIdentity", + defaultManagedIdentity: "defaultManagedIdentity", + readPermissionAssigned: "readPermissionAssigned", + pointInTimeRestore: "pointInTimeRestore", + onlineCopyEnabled: "onlineCopyEnabled", +} as const; + +const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [ + { + id: SECTION_IDS.addManagedIdentity, + title: ContainerCopyMessages.addManagedIdentity.title, + Component: AddManagedIdentity, + disabled: true, + validate: (state: CopyJobContextState) => { + const targetAccountIdentityType = (state?.target?.account?.identity?.type ?? "").toLowerCase(); + return ( + targetAccountIdentityType === IdentityType.SystemAssigned || + targetAccountIdentityType === IdentityType.UserAssigned + ); + }, + }, + { + id: SECTION_IDS.defaultManagedIdentity, + title: ContainerCopyMessages.defaultManagedIdentity.title, + Component: DefaultManagedIdentity, + disabled: true, + validate: (state: CopyJobContextState) => { + const targetAccountDefaultIdentity = (state?.target?.account?.properties?.defaultIdentity ?? "").toLowerCase(); + return targetAccountDefaultIdentity === DefaultIdentityType.SystemAssignedIdentity; + }, + }, + { + id: SECTION_IDS.readPermissionAssigned, + title: ContainerCopyMessages.readPermissionAssigned.title, + Component: AddReadPermissionToDefaultIdentity, + disabled: true, + validate: async (state: CopyJobContextState) => { + const principalId = state?.target?.account?.identity?.principalId; + const selectedSourceAccount = state?.source?.account; + const { + subscriptionId: sourceSubscriptionId, + resourceGroup: sourceResourceGroup, + accountName: sourceAccountName, + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + + const rolesAssigned = await fetchRoleAssignments( + sourceSubscriptionId, + sourceResourceGroup, + sourceAccountName, + principalId, + ); + + const roleDefinitions = await fetchRoleDefinitions(rolesAssigned ?? []); + return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []); + }, + }, +]; + +const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [ + { + id: SECTION_IDS.pointInTimeRestore, + title: ContainerCopyMessages.pointInTimeRestore.title, + Component: PointInTimeRestore, + disabled: true, + validate: (state: CopyJobContextState) => { + const sourceAccountBackupPolicy = state?.source?.account?.properties?.backupPolicy?.type ?? ""; + return sourceAccountBackupPolicy === BackupPolicyType.Continuous; + }, + }, + { + id: SECTION_IDS.onlineCopyEnabled, + title: ContainerCopyMessages.onlineCopyEnabled.title, + Component: OnlineCopyEnabled, + disabled: true, + validate: (state: CopyJobContextState) => { + const accountCapabilities = state?.source?.account?.properties?.capabilities ?? []; + const onlineCopyCapability = accountCapabilities.find( + (capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature, + ); + return !!onlineCopyCapability; + }, + }, +]; + +/** + * Checks if the user has the Reader role based on role definitions. + */ +export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean { + return roleDefinitions?.some( + (role) => + role.name === "00000000-0000-0000-0000-000000000001" || + role.permissions.some( + (permission) => + permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") && + permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read"), + ), + ); +} + +/** + * Returns the permission sections configuration for the Assign Permissions screen. + * Memoizes derived values for performance and decouples logic for testability. + */ +const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => { + const sourceAccountId = state?.source?.account?.id || ""; + const targetAccountId = state?.target?.account?.id || ""; + + const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache(); + const [permissionSections, setPermissionSections] = useState(null); + const isValidatingRef = useRef(false); + + const sectionToValidate = useMemo(() => { + const baseSections = sourceAccountId === targetAccountId ? [] : [...PERMISSION_SECTIONS_CONFIG]; + if (state.migrationType === CopyJobMigrationType.Online) { + return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS]; + } + return baseSections; + }, [sourceAccountId, targetAccountId, state.migrationType]); + + const memoizedValidationCache = useMemo(() => { + if (state.migrationType === CopyJobMigrationType.Offline) { + validationCache.delete(SECTION_IDS.pointInTimeRestore); + validationCache.delete(SECTION_IDS.onlineCopyEnabled); + } + return validationCache; + }, [state.migrationType]); + + useEffect(() => { + const validateSections = async () => { + if (isValidatingRef.current) { + return; + } + + isValidatingRef.current = true; + const result: PermissionSectionConfig[] = []; + const newValidationCache = new Map(memoizedValidationCache); + + for (let i = 0; i < sectionToValidate.length; i++) { + const section = sectionToValidate[i]; + + if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) { + result.push({ ...section, completed: true }); + continue; + } + if (section.validate) { + const isValid = await section.validate(state); + newValidationCache.set(section.id, isValid); + result.push({ ...section, completed: isValid }); + + if (!isValid) { + for (let j = i + 1; j < sectionToValidate.length; j++) { + result.push({ ...sectionToValidate[j], completed: false }); + } + break; + } + } else { + newValidationCache.set(section.id, false); + result.push({ ...section, completed: false }); + } + } + + setValidationCache(newValidationCache); + setPermissionSections(result); + isValidatingRef.current = false; + }; + + validateSections(); + + return () => { + isValidatingRef.current = false; + }; + }, [state, sectionToValidate]); + + return permissionSections ?? []; +}; + +export default usePermissionSections; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.tsx new file mode 100644 index 000000000..4f648e4c6 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.tsx @@ -0,0 +1,11 @@ +import { useCallback, useState } from "react"; + +const useToggle = (initialState = false) => { + const [state, setState] = useState(initialState); + const onToggle = useCallback((_, checked?: boolean) => { + setState(checked); + }, []); + return [state, onToggle] as const; +}; + +export default useToggle; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx new file mode 100644 index 000000000..fefcccc66 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx @@ -0,0 +1,25 @@ +import { Stack } from "@fluentui/react"; +import React from "react"; + +interface FieldRowProps { + label?: string; + children: React.ReactNode; + labelClassName?: string; +} + +const FieldRow: React.FC = ({ label = "", children, labelClassName = "" }) => { + return ( + + {label && ( + + + + )} + + {children} + + + ); +}; + +export default FieldRow; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.tsx new file mode 100644 index 000000000..24016e88b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.tsx @@ -0,0 +1,17 @@ +import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react"; +import React from "react"; +import InfoIcon from "../../../../../../images/Info.svg"; + +const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => { + if (!content) { + return null; + } + const hostStyles: Partial = { root: { display: "inline-block" } }; + return ( + + Information + + ); +}; + +export default React.memo(InfoTooltip); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx new file mode 100644 index 000000000..188c7d352 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx @@ -0,0 +1,28 @@ +import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react"; +import React from "react"; + +type NavigationControlsProps = { + primaryBtnText: string; + onPrimary: () => void; + onPrevious: () => void; + onCancel: () => void; + isPrimaryDisabled: boolean; + isPreviousDisabled: boolean; +}; + +const NavigationControls: React.FC = ({ + primaryBtnText, + onPrimary, + onPrevious, + onCancel, + isPrimaryDisabled, + isPreviousDisabled, +}) => ( + + + + + +); + +export default React.memo(NavigationControls); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx new file mode 100644 index 000000000..eec4f5402 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx @@ -0,0 +1,67 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/display-name */ +import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react"; +import React from "react"; + +interface PopoverContainerProps { + isLoading?: boolean; + title?: string; + children?: React.ReactNode; + onPrimary: () => void; + onCancel: () => void; +} + +const PopoverContainer: React.FC = React.memo( + ({ isLoading = false, title, children, onPrimary, onCancel }) => { + return ( + + + {title} + + {children} + + + + + + ); + }, +); + +interface PopoverMessageProps { + isLoading?: boolean; + visible: boolean; + title: string; + onCancel: () => void; + onPrimary: () => void; + children: React.ReactNode; +} + +const PopoverMessage: React.FC = ({ + isLoading = false, + visible, + title, + onCancel, + onPrimary, + children, +}) => { + if (!visible) { + return null; + } + return ( + + {children} + + ); +}; + +export default PopoverMessage; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx new file mode 100644 index 000000000..cd7f39bf1 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx @@ -0,0 +1,51 @@ +import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; +import React from "react"; +import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation"; +import NavigationControls from "./Components/NavigationControls"; + +const CreateCopyJobScreens: React.FC = () => { + const { + currentScreen, + isPrimaryDisabled, + isPreviousDisabled, + handlePrimary, + handlePrevious, + handleCancel, + primaryBtnText, + error, + setError, + } = useCopyJobNavigation(); + + return ( + + + {error && ( + setError(null)} + dismissButtonAriaLabel="Close" + truncated={true} + overflowButtonAriaLabel="See more" + > + {error} + + )} + {currentScreen?.component} + + + + + + ); +}; + +export default CreateCopyJobScreens; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx new file mode 100644 index 000000000..3436164e9 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import CopyJobContextProvider from "../../Context/CopyJobContext"; +import CreateCopyJobScreens from "./CreateCopyJobScreens"; + +const CreateCopyJobScreensProvider = () => { + return ( + + + + ); +}; + +export default CreateCopyJobScreensProvider; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx new file mode 100644 index 000000000..c270ccdf5 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx @@ -0,0 +1,52 @@ +import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react"; +import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils"; + +const PreviewCopyJob: React.FC = () => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + + const selectedDatabaseAndContainers = [ + { + sourceDatabaseName: copyJobState.source?.databaseId, + sourceContainerName: copyJobState.source?.containerId, + targetDatabaseName: copyJobState.target?.databaseId, + targetContainerName: copyJobState.target?.containerId, + }, + ]; + const jobName = copyJobState.jobName; + + const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => { + setCopyJobState((prevState) => ({ + ...prevState, + jobName: newValue || "", + })); + }; + return ( + + + + + + {ContainerCopyMessages.sourceSubscriptionLabel} + {copyJobState.source?.subscription?.displayName} + + + {ContainerCopyMessages.sourceAccountLabel} + {copyJobState.source?.account?.name} + + + + + + ); +}; + +export default PreviewCopyJob; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts new file mode 100644 index 000000000..814073767 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts @@ -0,0 +1,43 @@ +import { IColumn } from "@fluentui/react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; + +const commonProps = { + minWidth: 130, + maxWidth: 140, + styles: { + root: { + whiteSpace: "normal", + lineHeight: "1.2", + wordBreak: "break-word", + }, + }, +}; + +export const getPreviewCopyJobDetailsListColumns = (): IColumn[] => { + return [ + { + key: "sourcedbname", + name: ContainerCopyMessages.sourceDatabaseLabel, + fieldName: "sourceDatabaseName", + ...commonProps, + }, + { + key: "sourcecolname", + name: ContainerCopyMessages.sourceContainerLabel, + fieldName: "sourceContainerName", + ...commonProps, + }, + { + key: "targetdbname", + name: ContainerCopyMessages.targetDatabaseLabel, + fieldName: "targetDatabaseName", + ...commonProps, + }, + { + key: "targetcolname", + name: ContainerCopyMessages.targetContainerLabel, + fieldName: "targetContainerName", + ...commonProps, + }, + ]; +}; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx new file mode 100644 index 000000000..423920b43 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -0,0 +1,30 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/display-name */ +import { Dropdown } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import FieldRow from "../../Components/FieldRow"; + +interface AccountDropdownProps { + options: DropdownOptionType[]; + selectedKey?: string; + disabled: boolean; + onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void; +} + +export const AccountDropdown: React.FC = React.memo( + ({ options, selectedKey, disabled, onChange }) => ( + + + + ), +); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx new file mode 100644 index 000000000..a72965fc6 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx @@ -0,0 +1,16 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/display-name */ +import { Checkbox, Stack } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; + +interface MigrationTypeCheckboxProps { + checked: boolean; + onChange: (_ev?: React.FormEvent, checked?: boolean) => void; +} + +export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => ( + + + +)); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx new file mode 100644 index 000000000..67b8e4f87 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/display-name */ +import { Dropdown } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import FieldRow from "../../Components/FieldRow"; + +interface SubscriptionDropdownProps { + options: DropdownOptionType[]; + selectedKey?: string; + onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void; +} + +export const SubscriptionDropdown: React.FC = React.memo( + ({ options, selectedKey, onChange }) => ( + + + + ), +); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx new file mode 100644 index 000000000..2932e6229 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -0,0 +1,51 @@ +/* eslint-disable react/display-name */ +import { Stack } from "@fluentui/react"; +import React from "react"; +import { apiType } from "UserContext"; +import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels"; +import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts"; +import { useSubscriptions } from "../../../../../hooks/useSubscriptions"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { AccountDropdown } from "./Components/AccountDropdown"; +import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox"; +import { SubscriptionDropdown } from "./Components/SubscriptionDropdown"; +import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils"; + +const SelectAccount = React.memo(() => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + + const subscriptions: Subscription[] = useSubscriptions(); + const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId); + const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL"); + + const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts); + const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState); + + const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline; + + return ( + + {ContainerCopyMessages.selectAccountDescription} + + handleSelectSourceAccount("subscription", option?.data)} + /> + + handleSelectSourceAccount("account", option?.data)} + /> + + + + ); +}); + +export default SelectAccount; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx new file mode 100644 index 000000000..16d17c33b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; + +export function useDropdownOptions( + subscriptions: Subscription[], + accounts: DatabaseAccount[], +): { + subscriptionOptions: DropdownOptionType[]; + accountOptions: DropdownOptionType[]; +} { + const subscriptionOptions = React.useMemo( + () => + subscriptions?.map((sub) => ({ + key: sub.subscriptionId, + text: sub.displayName, + data: sub, + })) || [], + [subscriptions], + ); + + const accountOptions = React.useMemo( + () => + accounts?.map((account) => ({ + key: account.id, + text: account.name, + data: account, + })) || [], + [accounts], + ); + + return { subscriptionOptions, accountOptions }; +} + +type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"]; + +export function useEventHandlers(setCopyJobState: setCopyJobStateType) { + const handleSelectSourceAccount = React.useCallback( + (type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => { + setCopyJobState((prevState: CopyJobContextState) => { + if (type === "subscription") { + return { + ...prevState, + source: { + ...prevState.source, + subscription: data || null, + account: null, + }, + }; + } + if (type === "account") { + return { + ...prevState, + source: { + ...prevState.source, + account: data || null, + }, + }; + } + return prevState; + }); + }, + [setCopyJobState], + ); + + const handleMigrationTypeChange = React.useCallback( + (_ev?: React.FormEvent, checked?: boolean) => { + setCopyJobState((prevState: CopyJobContextState) => ({ + ...prevState, + migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, + })); + }, + [setCopyJobState], + ); + + return { handleSelectSourceAccount, handleMigrationTypeChange }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx new file mode 100644 index 000000000..1384d981c --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; + +export function dropDownChangeHandler(setCopyJobState: React.Dispatch>) { + return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") => + (_evnt: React.FormEvent, option: DropdownOptionType) => { + const value = option.key; + setCopyJobState((prevState) => { + switch (type) { + case "sourceDatabase": + return { + ...prevState, + source: { ...prevState.source, databaseId: value, containerId: undefined }, + }; + case "sourceContainer": + return { + ...prevState, + source: { ...prevState.source, containerId: value }, + }; + case "targetDatabase": + return { + ...prevState, + target: { ...prevState.target, databaseId: value, containerId: undefined }, + }; + case "targetContainer": + return { + ...prevState, + target: { ...prevState.target, containerId: value }, + }; + default: + return prevState; + } + }); + }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx new file mode 100644 index 000000000..fb42c3a69 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx @@ -0,0 +1,70 @@ +import { Stack } from "@fluentui/react"; +import { DatabaseModel } from "Contracts/DataModels"; +import React from "react"; +import { useDatabases } from "../../../../../hooks/useDatabases"; +import { useDataContainers } from "../../../../../hooks/useDataContainers"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { DatabaseContainerSection } from "./components/DatabaseContainerSection"; +import { dropDownChangeHandler } from "./Events/DropDownChangeHandler"; +import { useMemoizedSourceAndTargetData } from "./memoizedData"; + +const SelectSourceAndTargetContainers = () => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } = + useMemoizedSourceAndTargetData(copyJobState); + + const sourceDatabases = useDatabases(...sourceDbParams) || []; + const sourceContainers = useDataContainers(...sourceContainerParams) || []; + const targetDatabases = useDatabases(...targetDbParams) || []; + const targetContainers = useDataContainers(...targetContainerParams) || []; + + const sourceDatabaseOptions = React.useMemo( + () => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })), + [sourceDatabases], + ); + const sourceContainerOptions = React.useMemo( + () => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })), + [sourceContainers], + ); + const targetDatabaseOptions = React.useMemo( + () => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })), + [targetDatabases], + ); + const targetContainerOptions = React.useMemo( + () => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })), + [targetContainers], + ); + + const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]); + + return ( + + {ContainerCopyMessages.selectSourceAndTargetContainersDescription} + + + + ); +}; + +export default SelectSourceAndTargetContainers; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx new file mode 100644 index 000000000..1ecfaa4e9 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx @@ -0,0 +1,43 @@ +import { Dropdown, Stack } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes"; +import FieldRow from "../../Components/FieldRow"; + +export const DatabaseContainerSection = ({ + heading, + databaseOptions, + selectedDatabase, + databaseDisabled, + databaseOnChange, + containerOptions, + selectedContainer, + containerDisabled, + containerOnChange, +}: DatabaseContainerSectionProps) => ( + + + + + + + + + +); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx new file mode 100644 index 000000000..627bc8812 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; +import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes"; + +export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) { + const { source, target } = copyJobState ?? {}; + const selectedSourceAccount = source?.account; + const selectedTargetAccount = target?.account; + const { + subscriptionId: sourceSubscriptionId, + resourceGroup: sourceResourceGroup, + accountName: sourceAccountName, + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + const { + subscriptionId: targetSubscriptionId, + resourceGroup: targetResourceGroup, + accountName: targetAccountName, + } = getAccountDetailsFromResourceId(selectedTargetAccount?.id); + + const sourceDbParams = React.useMemo( + () => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams, + [sourceSubscriptionId, sourceResourceGroup, sourceAccountName], + ); + + const sourceContainerParams = React.useMemo( + () => + [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams, + [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId], + ); + + const targetDbParams = React.useMemo( + () => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams, + [targetSubscriptionId, targetResourceGroup, targetAccountName], + ); + + const targetContainerParams = React.useMemo( + () => + [targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams, + [targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId], + ); + + return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts new file mode 100644 index 000000000..4bd552455 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -0,0 +1,152 @@ +import { useCallback, useMemo, useReducer, useState } from "react"; +import { useSidePanel } from "../../../../hooks/useSidePanel"; +import { submitCreateCopyJob } from "../../Actions/CopyJobActions"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; +import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; +import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; + +type NavigationState = { + screenHistory: string[]; +}; + +type Action = { type: "NEXT"; nextScreen: string } | { type: "PREVIOUS" } | { type: "RESET" }; + +function navigationReducer(state: NavigationState, action: Action): NavigationState { + switch (action.type) { + case "NEXT": + return { + screenHistory: [...state.screenHistory, action.nextScreen], + }; + case "PREVIOUS": + return { + screenHistory: state.screenHistory.length > 1 ? state.screenHistory.slice(0, -1) : state.screenHistory, + }; + case "RESET": + return { + screenHistory: [SCREEN_KEYS.SelectAccount], + }; + default: + return state; + } +} + +export function useCopyJobNavigation() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { copyJobState, resetCopyJobState } = useCopyJobContext(); + const screens = useCreateCopyJobScreensList(); + const { validationCache: cache } = useCopyJobPrerequisitesCache(); + const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] }); + + const currentScreenKey = state.screenHistory[state.screenHistory.length - 1]; + const currentScreen = screens.find((screen) => screen.key === currentScreenKey); + + const isPrimaryDisabled = useMemo(() => { + if (isLoading) { + return true; + } + const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState; + return !currentScreen?.validations.every((v) => v.validate(context)); + }, [currentScreen.key, copyJobState, cache, isLoading]); + + const primaryBtnText = useMemo(() => { + if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { + return "Copy"; + } + return "Next"; + }, [currentScreenKey]); + + const isPreviousDisabled = state.screenHistory.length <= 1; + + const handleCancel = useCallback(() => { + dispatch({ type: "RESET" }); + resetCopyJobState(); + useSidePanel.getState().closeSidePanel(); + }, []); + + const getContainerIdentifiers = (container: typeof copyJobState.source | typeof copyJobState.target) => ({ + accountId: container?.account?.id || "", + databaseId: container?.databaseId || "", + containerId: container?.containerId || "", + }); + + const isSameAccount = ( + sourceIds: ReturnType, + targetIds: ReturnType, + ) => sourceIds.accountId === targetIds.accountId; + + const areContainersIdentical = () => { + const { source, target } = copyJobState; + const sourceIds = getContainerIdentifiers(source); + const targetIds = getContainerIdentifiers(target); + + return ( + isSameAccount(sourceIds, targetIds) && + sourceIds.databaseId === targetIds.databaseId && + sourceIds.containerId === targetIds.containerId + ); + }; + + const shouldNotShowPermissionScreen = () => { + const { source, target, migrationType } = copyJobState; + return ( + migrationType === CopyJobMigrationType.Offline && + isSameAccount(getContainerIdentifiers(source), getContainerIdentifiers(target)) + ); + }; + + const handleCopyJobSubmission = async () => { + try { + setIsLoading(true); + await submitCreateCopyJob(copyJobState, handleCancel); + } catch (error: unknown) { + const errorMessage = + error instanceof Error + ? error.message || "Failed to create copy job. Please try again later." + : "Failed to create copy job. Please try again later."; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handlePrimary = useCallback(() => { + if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) { + setError("Source and destination containers cannot be the same. Please select different containers to proceed."); + return; + } + + setError(null); + const transitions = { + [SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen() + ? SCREEN_KEYS.SelectSourceAndTargetContainers + : SCREEN_KEYS.AssignPermissions, + [SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers, + [SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob, + }; + + const nextScreen = transitions[currentScreenKey]; + if (nextScreen) { + dispatch({ type: "NEXT", nextScreen }); + } else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { + handleCopyJobSubmission(); + } + }, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]); + + const handlePrevious = useCallback(() => { + dispatch({ type: "PREVIOUS" }); + }, []); + + return { + currentScreen, + isPrimaryDisabled, + isPreviousDisabled, + handlePrimary, + handlePrevious, + handleCancel, + primaryBtnText, + error, + setError, + }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx new file mode 100644 index 000000000..457abcbe1 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx @@ -0,0 +1,11 @@ +import create from "zustand"; + +interface CopyJobPrerequisitesCacheState { + validationCache: Map; + setValidationCache: (cache: Map) => void; +} + +export const useCopyJobPrerequisitesCache = create((set) => ({ + validationCache: new Map(), + setValidationCache: (cache) => set({ validationCache: cache }), +})); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx new file mode 100644 index 000000000..1b0c74f05 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { CopyJobContextState } from "../../Types/CopyJobTypes"; +import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions"; +import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob"; +import SelectAccount from "../Screens/SelectAccount/SelectAccount"; +import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers"; + +const SCREEN_KEYS = { + SelectAccount: "SelectAccount", + SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", + PreviewCopyJob: "PreviewCopyJob", + AssignPermissions: "AssignPermissions", +}; + +type Validation = { + validate: (state: CopyJobContextState | Map) => boolean; + message: string; +}; + +type Screen = { + key: string; + component: React.ReactElement; + validations: Validation[]; +}; + +function useCreateCopyJobScreensList() { + return React.useMemo( + () => [ + { + key: SCREEN_KEYS.SelectAccount, + component: , + validations: [ + { + validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account, + message: "Please select a subscription and account to proceed", + }, + ], + }, + { + key: SCREEN_KEYS.SelectSourceAndTargetContainers, + component: , + validations: [ + { + validate: (state: CopyJobContextState) => + !!state?.source?.databaseId && + !!state?.source?.containerId && + !!state?.target?.databaseId && + !!state?.target?.containerId, + message: "Please select source and target containers to proceed", + }, + ], + }, + { + key: SCREEN_KEYS.PreviewCopyJob, + component: , + validations: [ + { + validate: (state: CopyJobContextState) => + !!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)), + message: "Please enter a job name to proceed", + }, + ], + }, + { + key: SCREEN_KEYS.AssignPermissions, + component: , + validations: [ + { + validate: (cache: Map) => { + const cacheValuesIterator = Array.from(cache.values()); + if (cacheValuesIterator.length === 0) { + return false; + } + + const allValid = cacheValuesIterator.every((isValid: boolean) => isValid); + return allValid; + }, + message: "Please ensure all previous steps are valid to proceed", + }, + ], + }, + ], + [], + ); +} + +export { SCREEN_KEYS, useCreateCopyJobScreensList }; diff --git a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts new file mode 100644 index 000000000..9be43bcc8 --- /dev/null +++ b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts @@ -0,0 +1,39 @@ +export enum CopyJobMigrationType { + Offline = "offline", + Online = "online", +} + +export enum IdentityType { + SystemAssigned = "systemassigned", + UserAssigned = "userassigned", + None = "none", +} + +export enum DefaultIdentityType { + SystemAssignedIdentity = "systemassignedidentity", +} + +export enum BackupPolicyType { + Continuous = "Continuous", + Periodic = "Periodic", +} + +export enum CopyJobStatusType { + Pending = "Pending", + InProgress = "InProgress", + Running = "Running", + Partitioning = "Partitioning", + Paused = "Paused", + Skipped = "Skipped", + Completed = "Completed", + Cancelled = "Cancelled", + Failed = "Failed", + Faulted = "Faulted", +} + +export enum CopyJobActions { + pause = "pause", + resume = "resume", + cancel = "cancel", + complete = "complete", +} diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx new file mode 100644 index 000000000..57bc99acd --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -0,0 +1,89 @@ +import { IconButton, IContextualMenuProps } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; + +interface CopyJobActionMenuProps { + job: CopyJobType; + handleClick: HandleJobActionClickType; +} + +const CopyJobActionMenu: React.FC = ({ job, handleClick }) => { + const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null); + if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) { + return null; + } + + const getMenuItems = (): IContextualMenuProps["items"] => { + const isThisJobUpdating = updatingJobAction?.jobName === job.Name; + const updatingAction = updatingJobAction?.action; + + const baseItems = [ + { + key: CopyJobActions.pause, + text: ContainerCopyMessages.MonitorJobs.Actions.pause, + iconProps: { iconName: "Pause" }, + onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction), + disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause, + }, + { + key: CopyJobActions.cancel, + text: ContainerCopyMessages.MonitorJobs.Actions.cancel, + iconProps: { iconName: "Cancel" }, + onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction), + disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel, + }, + { + key: CopyJobActions.resume, + text: ContainerCopyMessages.MonitorJobs.Actions.resume, + iconProps: { iconName: "Play" }, + onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction), + disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume, + }, + ]; + + if (CopyJobStatusType.Paused === job.Status) { + return baseItems.filter((item) => item.key !== CopyJobActions.pause); + } + + if (CopyJobStatusType.Pending === job.Status) { + return baseItems.filter((item) => item.key !== CopyJobActions.resume); + } + + if ( + [CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status) + ) { + const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume); + if (job.Mode === CopyJobMigrationType.Online) { + filteredItems.push({ + key: CopyJobActions.complete, + text: ContainerCopyMessages.MonitorJobs.Actions.complete, + iconProps: { iconName: "CheckMark" }, + onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction), + disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete, + }); + } + return filteredItems; + } + + if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) { + return baseItems.filter((item) => item.key === CopyJobActions.resume); + } + + return baseItems; + }; + + return ( + + ); +}; + +export default CopyJobActionMenu; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx new file mode 100644 index 000000000..a1c7e1417 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.tsx @@ -0,0 +1,80 @@ +import { IColumn } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import CopyJobActionMenu from "./CopyJobActionMenu"; +import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon"; + +export const getColumns = ( + handleSort: (columnKey: string) => void, + handleActionClick: HandleJobActionClickType, + sortedColumnKey: string | undefined, + isSortedDescending: boolean, +): IColumn[] => [ + { + key: "LastUpdatedTime", + name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime, + fieldName: "LastUpdatedTime", + minWidth: 140, + maxWidth: 300, + isResizable: true, + isSorted: sortedColumnKey === "timestamp", + isSortedDescending: isSortedDescending, + onColumnClick: () => handleSort("timestamp"), + }, + { + key: "Name", + name: ContainerCopyMessages.MonitorJobs.Columns.name, + fieldName: "Name", + minWidth: 140, + maxWidth: 300, + isResizable: true, + isSorted: sortedColumnKey === "Name", + isSortedDescending: isSortedDescending, + onColumnClick: () => handleSort("Name"), + onRender: (job: CopyJobType) => {job.Name}, + }, + { + key: "Mode", + name: ContainerCopyMessages.MonitorJobs.Columns.mode, + fieldName: "Mode", + minWidth: 90, + maxWidth: 200, + isResizable: true, + isSorted: sortedColumnKey === "Mode", + isSortedDescending: isSortedDescending, + onColumnClick: () => handleSort("Mode"), + }, + { + key: "CompletionPercentage", + name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage, + fieldName: "CompletionPercentage", + minWidth: 110, + maxWidth: 200, + isResizable: true, + isSorted: sortedColumnKey === "CompletionPercentage", + isSortedDescending: isSortedDescending, + onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`, + onColumnClick: () => handleSort("CompletionPercentage"), + }, + { + key: "CopyJobStatus", + name: ContainerCopyMessages.MonitorJobs.Columns.status, + fieldName: "Status", + minWidth: 130, + maxWidth: 200, + isResizable: true, + isSorted: sortedColumnKey === "Status", + isSortedDescending: isSortedDescending, + onRender: (job: CopyJobType) => , + onColumnClick: () => handleSort("Status"), + }, + { + key: "Actions", + name: "", + minWidth: 80, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => , + }, +]; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx new file mode 100644 index 000000000..f131cc4e5 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx @@ -0,0 +1,118 @@ +import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react"; +import React, { memo } from "react"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType } from "../../Types/CopyJobTypes"; +import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon"; + +interface CopyJobDetailsProps { + job: CopyJobType; +} + +const sectionCss = { + verticalAlign: { display: "flex", flexDirection: "column" } as React.CSSProperties, + headingText: { marginBottom: "10px" } as React.CSSProperties, +}; + +const commonProps = { + minWidth: 100, + maxWidth: 130, + styles: { + root: { + whiteSpace: "normal", + lineHeight: "1.2", + wordBreak: "break-word", + }, + }, +}; + +const getCopyJobDetailsListColumns = (): IColumn[] => { + return [ + { + key: "sourcedbcol", + name: ContainerCopyMessages.sourceDatabaseLabel, + fieldName: "sourceDatabaseName", + ...commonProps, + }, + { + key: "sourcecol", + name: ContainerCopyMessages.sourceContainerLabel, + fieldName: "sourceContainerName", + ...commonProps, + }, + { + key: "targetdbcol", + name: ContainerCopyMessages.targetDatabaseLabel, + fieldName: "targetDatabaseName", + ...commonProps, + }, + { + key: "targetcol", + name: ContainerCopyMessages.targetContainerLabel, + fieldName: "targetContainerName", + ...commonProps, + }, + { + key: "statuscol", + name: ContainerCopyMessages.MonitorJobs.Columns.status, + fieldName: "jobStatus", + onRender: ({ jobStatus }: { jobStatus: CopyJobStatusType }) => , + ...commonProps, + }, + ]; +}; + +const CopyJobDetails: React.FC = ({ job }) => { + const selectedContainers = [ + { + sourceContainerName: job?.Source?.containerName || "N/A", + sourceDatabaseName: job?.Source?.databaseName || "N/A", + targetContainerName: job?.Destination?.containerName || "N/A", + targetDatabaseName: job?.Destination?.databaseName || "N/A", + jobStatus: job?.Status || "", + }, + ]; + + return ( + + {job.Error ? ( + + + {ContainerCopyMessages.errorTitle} + + + {job.Error.message} + + + ) : null} + + + + {ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime} + {job.LastUpdatedTime} + + + {ContainerCopyMessages.sourceAccountLabel} + {job.Source?.remoteAccountName} + + + {ContainerCopyMessages.MonitorJobs.Columns.mode} + {job.Mode} + + + + + + + + ); +}; + +export default memo(CopyJobDetails, (prevProps, nextProps) => { + return prevProps.job.ID === nextProps.job.ID && prevProps.job.Error === nextProps.job.Error; +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx new file mode 100644 index 000000000..b10d69087 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx @@ -0,0 +1,62 @@ +import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; + +const theme = getTheme(); + +const iconClass = mergeStyles({ + fontSize: "16px", + marginRight: "8px", +}); + +const classNames = mergeStyleSets({ + [CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass], + [CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass], + [CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass], + [CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass], + [CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass], + [CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass], + [CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass], + [CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass], + [CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass], + [CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass], + unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass], +}); + +const iconMap: Partial> = { + [CopyJobStatusType.Pending]: "Clock", + [CopyJobStatusType.Paused]: "CirclePause", + [CopyJobStatusType.Skipped]: "StatusCircleBlock2", + [CopyJobStatusType.Cancelled]: "StatusErrorFull", + [CopyJobStatusType.Failed]: "StatusErrorFull", + [CopyJobStatusType.Faulted]: "StatusErrorFull", + [CopyJobStatusType.Completed]: "CompletedSolid", +}; + +const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => { + const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown"; + + const isSpinnerStatus = [ + CopyJobStatusType.Running, + CopyJobStatusType.InProgress, + CopyJobStatusType.Partitioning, + ].includes(status); + + return ( + + {isSpinnerStatus ? ( + + ) : ( + + )} + {statusText} + + ); +}; + +export default CopyJobStatusWithIcon; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx new file mode 100644 index 000000000..d7f32d3e8 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx @@ -0,0 +1,22 @@ +import { ActionButton, Image } from "@fluentui/react"; +import React, { useCallback } from "react"; +import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg"; +import * as Actions from "../../Actions/CopyJobActions"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; + +interface CopyJobsNotFoundProps {} + +const CopyJobsNotFound: React.FC = () => { + const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []); + return ( +
+ {ContainerCopyMessages.noCopyJobsTitle} +

{ContainerCopyMessages.noCopyJobsTitle}

+ + {ContainerCopyMessages.createCopyJobButtonText} + +
+ ); +}; + +export default CopyJobsNotFound; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx new file mode 100644 index 000000000..6fdf915d6 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + ConstrainMode, + DetailsListLayoutMode, + DetailsRow, + IColumn, + ScrollablePane, + ScrollbarVisibility, + ShimmeredDetailsList, + Stack, + Sticky, + StickyPositionType, +} from "@fluentui/react"; +import React, { useEffect } from "react"; +import Pager from "../../../../Common/Pager"; +import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import { getColumns } from "./CopyJobColumns"; + +interface CopyJobsListProps { + jobs: CopyJobType[]; + handleActionClick: HandleJobActionClickType; + pageSize?: number; +} + +const styles = { + container: { height: "calc(100vh - 25em)" } as React.CSSProperties, + stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties, +}; + +const PAGE_SIZE = 10; + +const CopyJobsList: React.FC = ({ 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); + setStartIndex(0); + }, [jobs]); + + const handleSort = (columnKey: string) => { + const isDescending = sortedColumnKey === columnKey ? !isSortedDescending : false; + const sorted = [...sortedJobs].sort((current: any, next: any) => { + if (current[columnKey] < next[columnKey]) { + return isDescending ? 1 : -1; + } + if (current[columnKey] > next[columnKey]) { + return isDescending ? -1 : 1; + } + return 0; + }); + setSortedJobs(sorted); + setSortedColumnKey(columnKey); + setIsSortedDescending(isDescending); + setStartIndex(0); + }; + + const columns: IColumn[] = React.useMemo( + () => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending), + [handleSort, handleActionClick, sortedColumnKey, isSortedDescending], + ); + + const _handleRowClick = React.useCallback((job: CopyJobType) => { + openCopyJobDetailsPanel(job); + }, []); + + const _onRenderRow = React.useCallback((props: any) => { + return ( +
+ +
+ ); + }, []); + + return ( +
+ + + + ( + + {defaultRender({ ...props })} + + )} + /> + + + {sortedJobs.length > pageSize && ( + + { + setStartIndex(startIdx); + }} + showFirstLast={true} + showItemCount={true} + /> + + )} + +
+ ); +}; + +export default CopyJobsList; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.tsx new file mode 100644 index 000000000..14fbc4ae3 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.tsx @@ -0,0 +1,12 @@ +import create from "zustand"; +import { MonitorCopyJobsRef } from "./MonitorCopyJobs"; + +type MonitorCopyJobsRefStateType = { + ref: MonitorCopyJobsRef; + setRef: (ref: MonitorCopyJobsRef) => void; +}; + +export const MonitorCopyJobsRefState = create((set) => ({ + ref: null, + setRef: (ref) => set({ ref: ref }), +})); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx new file mode 100644 index 000000000..cb4d0fea8 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -0,0 +1,122 @@ +/* eslint-disable react/display-name */ +import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; +import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree"; +import React, { forwardRef, useEffect, useImperativeHandle } from "react"; +import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions"; +import { convertToCamelCase } from "../CopyJobUtils"; +import { CopyJobStatusType } from "../Enums/CopyJobEnums"; +import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound"; +import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes"; +import CopyJobsList from "./Components/CopyJobsList"; + +const FETCH_INTERVAL_MS = 30 * 1000; + +interface MonitorCopyJobsProps {} + +export interface MonitorCopyJobsRef { + refreshJobList: () => void; +} + +const MonitorCopyJobs = forwardRef((_props, ref) => { + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [jobs, setJobs] = React.useState([]); + const isUpdatingRef = React.useRef(false); + const isFirstFetchRef = React.useRef(true); + + const indentLevels = React.useMemo(() => Array(7).fill({ level: 0, width: "100%" }), []); + + const fetchJobs = React.useCallback(async () => { + if (isUpdatingRef.current) { + return; + } + try { + if (isFirstFetchRef.current) { + setLoading(true); + } + setError(null); + + const response = await getCopyJobs(); + setJobs((prevJobs) => { + const isSame = JSON.stringify(prevJobs) === JSON.stringify(response); + return isSame ? prevJobs : response; + }); + } catch (error) { + setError(error.message || "Failed to load copy jobs. Please try again later."); + } finally { + if (isFirstFetchRef.current) { + setLoading(false); + isFirstFetchRef.current = false; + } + } + }, []); + + useEffect(() => { + fetchJobs(); + const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS); + + return () => clearInterval(intervalId); + }, [fetchJobs]); + + useImperativeHandle(ref, () => ({ + refreshJobList: () => { + if (isUpdatingRef.current) { + setError("Please wait for the current update to complete before refreshing."); + return; + } + fetchJobs(); + }, + })); + + const handleActionClick = React.useCallback( + async (job: CopyJobType, action: string, setUpdatingJobAction: JobActionUpdatorType) => { + try { + isUpdatingRef.current = true; + setUpdatingJobAction({ jobName: job.Name, action }); + const updatedCopyJob = await updateCopyJobStatus(job, action); + if (updatedCopyJob) { + setJobs((prevJobs) => + prevJobs.map((prevJob) => + prevJob.Name === updatedCopyJob.properties.jobName + ? { + ...prevJob, + Status: convertToCamelCase(updatedCopyJob.properties.status) as CopyJobStatusType, + } + : prevJob, + ), + ); + } + } catch (error) { + setError(error.message || "Failed to update copy job status. Please try again later."); + } finally { + isUpdatingRef.current = false; + setUpdatingJobAction(null); + } + }, + [], + ); + + const memoizedJobsList = React.useMemo(() => { + if (loading) { + return null; + } + if (jobs.length > 0) { + return ; + } + return ; + }, [jobs, loading, handleActionClick]); + + return ( + + {loading && } + {error && ( + setError(null)}> + {error} + + )} + {memoizedJobsList} + + ); +}); + +export default MonitorCopyJobs; diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts new file mode 100644 index 000000000..1c031bb33 --- /dev/null +++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts @@ -0,0 +1,143 @@ +import { DatabaseAccount, Subscription } from "Contracts/DataModels"; +import React from "react"; +import { ApiType } from "UserContext"; +import { CosmosSqlDataTransferDataSourceSink } from "../../../Utils/arm/generatedClients/dataTransferService/types"; +import Explorer from "../../Explorer"; +import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums"; + +export interface ContainerCopyProps { + container: Explorer; +} + +export type CopyJobCommandBarBtnType = { + key: string; + iconSrc: string; + label: string; + ariaLabel: string; + disabled?: boolean; + onClick: () => void; +}; + +export type CopyJobTabForwardRefHandle = { + validate: (state: CopyJobContextState) => boolean; +}; + +export type DropdownOptionType = { + key: string; + text: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; +}; + +export type DatabaseParams = [string | undefined, string | undefined, string | undefined, ApiType]; +export type DataContainerParams = [ + string | undefined, + string | undefined, + string | undefined, + string | undefined, + ApiType, +]; + +export interface DatabaseContainerSectionProps { + heading: string; + databaseOptions: DropdownOptionType[]; + selectedDatabase: string; + databaseDisabled?: boolean; + databaseOnChange: (ev: React.FormEvent, option: DropdownOptionType) => void; + containerOptions: DropdownOptionType[]; + selectedContainer: string; + containerDisabled?: boolean; + containerOnChange: (ev: React.FormEvent, option: DropdownOptionType) => void; +} + +export interface CopyJobContextState { + jobName: string; + migrationType: CopyJobMigrationType; + sourceReadAccessFromTarget?: boolean; + source: { + subscription: Subscription; + account: DatabaseAccount; + databaseId: string; + containerId: string; + }; + target: { + subscriptionId: string; + account: DatabaseAccount; + databaseId: string; + containerId: string; + }; +} + +export interface CopyJobFlowType { + currentScreen: string; +} + +export interface CopyJobContextProviderType { + flow: CopyJobFlowType; + setFlow: React.Dispatch>; + copyJobState: CopyJobContextState | null; + setCopyJobState: React.Dispatch>; + resetCopyJobState: () => void; +} + +export type CopyJobType = { + ID: string; + Mode: string; + Name: string; + Status: CopyJobStatusType; + CompletionPercentage: number; + Duration: string; + LastUpdatedTime: string; + timestamp: number; + Error?: CopyJobErrorType; + Source: CosmosSqlDataTransferDataSourceSink; + Destination: CosmosSqlDataTransferDataSourceSink; +}; + +export interface CopyJobErrorType { + message: string; + code: string; +} + +export interface CopyJobError { + message: string; + navigateToStep?: number; +} + +export type DataTransferJobType = { + id: string; + type: string; + properties: { + jobName: string; + status: string; + lastUpdatedUtcTime: string; + processedCount: number; + totalCount: number; + mode: string; + duration: string; + source: { + databaseName: string; + collectionName: string; + component: string; + }; + destination: { + databaseName: string; + collectionName: string; + component: string; + }; + error: { + message: string; + code: string; + }; + }; +}; + +export type JobActionUpdatorType = React.Dispatch>; + +export type HandleJobActionClickType = ( + job: CopyJobType, + action: string, + setUpdatingJobAction: JobActionUpdatorType, +) => void; + +export type AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => boolean; diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less new file mode 100644 index 000000000..25ebb0311 --- /dev/null +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -0,0 +1,160 @@ +@import "../../../less/Common/Constants.less"; + +#containerCopyWrapper { + .centerContent { + justify-content: center; + align-items: center; + } + .notFoundContainer { + .noCopyJobsMessage { + font-weight: 600; + margin: 0 auto; + color: @FocusColor; + } + button.createCopyJobButton { + color: @LinkColor; + } + } +} +.createCopyJobScreensContainer { + height: 100%; + padding: 1em 1.5em; + + label { + padding: 0; + } + .flex-row { + display: flex; + flex-direction: row; + label.field-label { + font-weight: 600; + } + .flex-fixed-width { + flex: 0 0 auto; + width: 150px; + } + .flex-grow-col { + flex: 1 1 auto; + } + } + .databaseContainerSection { + label.subHeading { + font: inherit; + padding: unset; + font-weight: 600; + } + } + + .accordionHeader { + button { + display: flex; + align-items: center; + .accordionHeaderText { + margin-left: 5px; + font-weight: 600; + } + .statusIcon { + margin-left: auto; + } + } + } + .popover-container { + button[disabled] { + cursor: not-allowed; + opacity: 0.8; + } + } + .foreground { + z-index: 10; + background-color: white; + padding: 20px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + transform: translate(0%, -9%); + position: absolute; + } + .createCopyJobErrorMessageBar { + margin-bottom: 2em; + } +} + +.monitorCopyJobs { + padding: 0; + width: 100%; + max-width: 100%; + margin: 0 auto; + + .ms-DetailsList { + width: 100%; + + .ms-DetailsHeader { + .ms-DetailsHeader-cell { + padding: @DefaultSpace 20px; + font-weight: 600; + font-size: @DefaultFontSize; + color: @BaseHigh; + background-color: @BaseLow; + border-bottom: @ButtonBorderWidth solid @BaseMedium; + + &:hover { + background-color: @BaseMediumLow; + } + } + } + + .ms-DetailsRow { + border-bottom: @ButtonBorderWidth solid @BaseMedium; + + &:hover { + background-color: @BaseMediumLow; + } + + .ms-DetailsRow-cell { + padding: @MediumSpace 20px; + font-size: @DefaultFontSize; + color: @BaseHigh; + min-height: 48px; + display: flex; + align-items: center; + + .jobNameLink { + color: @LinkColor; + text-decoration: underline; + cursor: pointer; + } + } + } + } + + button[role="button"] { + &.ms-Button--icon { + i.ms-Icon { + font-size: @LargeSpace; + } + } + } +} + +.copyJobDetailsContainer { + padding: 1em 0 0 2em; + + .ms-DetailsList { + width: 100%; + .ms-DetailsHeader-cellTitle, .ms-DetailsRow-cell { + padding-left: 0; + } + .ms-DetailsRow-cell { + font-size: @DefaultFontSize; + color: @BaseHigh; + } + } +} + +.bold { + font-weight: 600; +} + +.fullWidth { + width: 100%; + display: flex; + justify-content: center; +} \ No newline at end of file diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 27c97417d..75abd2f47 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -359,6 +359,14 @@ export default class Explorer { ); } + public async openContainerCopyFeedbackBlade(): Promise { + sendMessage({ type: MessageTypes.OpenContainerCopyFeedbackBlade }); + Logger.logInfo( + `Container Copy Feedback logging current date when survey is shown ${Date.now().toString()}`, + "Explorer/openContainerCopyFeedbackBlade", + ); + } + public async refreshDatabaseForResourceToken(): Promise { const databaseId = userContext.parsedResourceToken?.databaseId; const collectionId = userContext.parsedResourceToken?.collectionId; diff --git a/src/Explorer/Panes/PanelContainerComponent.test.tsx b/src/Explorer/Panes/PanelContainerComponent.test.tsx index c06f33ddf..c0a53abce 100644 --- a/src/Explorer/Panes/PanelContainerComponent.test.tsx +++ b/src/Explorer/Panes/PanelContainerComponent.test.tsx @@ -3,11 +3,24 @@ import React from "react"; import { PanelContainerComponent, PanelContainerProps } from "./PanelContainerComponent"; describe("PaneContainerComponent test", () => { + it("should not render console with panel", () => { + const panelContainerProps: PanelContainerProps = { + headerText: "test", + panelContent:
, + isOpen: true, + hasConsole: false, + isConsoleExpanded: false, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + it("should render with panel content and header", () => { const panelContainerProps: PanelContainerProps = { headerText: "test", panelContent:
, isOpen: true, + hasConsole: true, isConsoleExpanded: false, }; const wrapper = shallow(); @@ -19,6 +32,7 @@ describe("PaneContainerComponent test", () => { headerText: "test", panelContent: undefined, isOpen: true, + hasConsole: true, isConsoleExpanded: false, }; const wrapper = shallow(); @@ -30,6 +44,7 @@ describe("PaneContainerComponent test", () => { headerText: "test", panelContent:
, isOpen: true, + hasConsole: true, isConsoleExpanded: true, }; const wrapper = shallow(); diff --git a/src/Explorer/Panes/PanelContainerComponent.tsx b/src/Explorer/Panes/PanelContainerComponent.tsx index 5795ec941..1cf8d6ffc 100644 --- a/src/Explorer/Panes/PanelContainerComponent.tsx +++ b/src/Explorer/Panes/PanelContainerComponent.tsx @@ -8,6 +8,7 @@ export interface PanelContainerProps { panelContent?: JSX.Element; isConsoleExpanded: boolean; isOpen: boolean; + hasConsole: boolean; isConsoleAnimationFinished?: boolean; panelWidth?: string; onRenderNavigationContent?: IRenderFunction; @@ -86,6 +87,9 @@ export class PanelContainerComponent extends React.Component { + if (!this.props.hasConsole) { + return window.innerHeight + "px"; + } const notificationConsole = document.getElementById("explorerNotificationConsole"); if (notificationConsole) { return window.innerHeight - notificationConsole.clientHeight + "px"; @@ -102,9 +106,10 @@ export class PanelContainerComponent extends React.Component { const isConsoleExpanded = useNotificationConsole((state) => state.isExpanded); const isConsoleAnimationFinished = useNotificationConsole((state) => state.consoleAnimationFinished); - const { isOpen, panelContent, panelWidth, headerText } = useSidePanel((state) => { + const { isOpen, hasConsole, panelContent, panelWidth, headerText } = useSidePanel((state) => { return { isOpen: state.isOpen, + hasConsole: state.hasConsole, panelContent: state.panelContent, headerText: state.headerText, panelWidth: state.panelWidth, @@ -114,6 +119,7 @@ export const SidePanel: React.FC = () => { // This component only exists so we can use hooks and pass them down to a non-functional component return ( `; +exports[`PaneContainerComponent test should not render console with panel 1`] = ` + +
+ +`; + exports[`PaneContainerComponent test should render nothing if content is undefined 1`] = ``; exports[`PaneContainerComponent test should render with panel content and header 1`] = ` diff --git a/src/Main.tsx b/src/Main.tsx index 52972b462..e223f58f7 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -20,9 +20,12 @@ import "../externals/jquery.typeahead.min.css"; import "../externals/jquery.typeahead.min.js"; // Image Dependencies import { Platform } from "ConfigContext"; +import ContainerCopyPanel from "Explorer/ContainerCopy/ContainerCopyPanel"; +import Explorer from "Explorer/Explorer"; import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel"; import { SidebarContainer } from "Explorer/Sidebar"; import { KeyboardShortcutRoot } from "KeyboardShortcuts"; +import { userContext } from "UserContext"; import "allotment/dist/style.css"; import "../images/CosmosDB_rgb_ui_lighttheme.ico"; import hdeConnectImage from "../images/HdeConnectCosmosDB.svg"; @@ -83,22 +86,12 @@ const App: React.FunctionComponent = () => { return (
-
-
- {/* Main Command Bar - Start */} - - {/* Collections Tree and Tabs - Begin */} - - {/* Collections Tree and Tabs - End */} - -
+ {userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? ( + + ) : ( + + )} + {} @@ -113,6 +106,27 @@ const App: React.FunctionComponent = () => { const mainElement = document.getElementById("Main"); ReactDOM.render(, mainElement); +function DivExplorer({ explorer }: { explorer: Explorer }): JSX.Element { + return ( +
+
+ {/* Main Command Bar - Start */} + + {/* Collections Tree and Tabs - Begin */} + + {/* Collections Tree and Tabs - End */} + +
+ ); +} + function LoadingExplorer(): JSX.Element { return (
diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 685930b43..80a5494a4 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -39,6 +39,7 @@ export type Features = { readonly copilotChatFixedMonacoEditorHeight: boolean; readonly enablePriorityBasedExecution: boolean; readonly disableConnectionStringLogin: boolean; + readonly enableContainerCopy: boolean; readonly enableCloudShell: boolean; // can be set via both flight and feature flag @@ -111,6 +112,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"), enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), + enableContainerCopy: "true" === get("enablecontainercopy"), enableCloudShell: true, }; } diff --git a/src/UserContext.ts b/src/UserContext.ts index 8a723ad91..ecc4f5807 100644 --- a/src/UserContext.ts +++ b/src/UserContext.ts @@ -176,7 +176,7 @@ function updateUserContext(newContext: Partial): void { Object.assign(userContext, newContext); } -function apiType(account: DatabaseAccount | undefined): ApiType { +export function apiType(account: DatabaseAccount | undefined): ApiType { if (!account) { return "SQL"; } diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index bc47c7a5f..650f2ed17 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -8,6 +8,7 @@ describe("AuthorizationUtils", () => { const setAadDataPlane = (enabled: boolean) => { updateUserContext({ features: { + enableContainerCopy: false, enableAadDataPlane: enabled, canExceedMaximumValue: false, cosmosdb: false, diff --git a/src/Utils/CopyJobAuthUtils.ts b/src/Utils/CopyJobAuthUtils.ts new file mode 100644 index 000000000..19e8f5b18 --- /dev/null +++ b/src/Utils/CopyJobAuthUtils.ts @@ -0,0 +1,12 @@ +import { userContext } from "UserContext"; + +export function getCopyJobAuthorizationHeader(token: string = ""): Headers { + if (!token && !userContext.authorizationToken) { + throw new Error("Authorization token is missing"); + } + const headers = new Headers(); + const authToken = token ? `Bearer ${token}` : userContext.authorizationToken ?? ""; + headers.append("Authorization", authToken); + headers.append("Content-Type", "application/json"); + return headers; +} diff --git a/src/Utils/arm/RbacUtils.ts b/src/Utils/arm/RbacUtils.ts new file mode 100644 index 000000000..739f327d2 --- /dev/null +++ b/src/Utils/arm/RbacUtils.ts @@ -0,0 +1,115 @@ +import { configContext } from "ConfigContext"; +import { buildArmUrl } from "Utils/arm/armUtils"; +import { armRequest } from "Utils/arm/request"; +import { getCopyJobAuthorizationHeader } from "../CopyJobAuthUtils"; + +export type FetchAccountDetailsParams = { + subscriptionId: string; + resourceGroupName: string; + accountName: string; +}; + +export type RoleAssignmentPropertiesType = { + roleDefinitionId: string; + principalId: string; + scope: string; +}; + +export type RoleAssignmentType = { + id: string; + name: string; + properties: RoleAssignmentPropertiesType; + type: string; +}; + +type RoleDefinitionDataActions = { + dataActions: string[]; +}; + +export type RoleDefinitionType = { + assignableScopes: string[]; + id: string; + name: string; + permissions: RoleDefinitionDataActions[]; + resourceGroup: string; + roleName: string; + type: string; + typePropertiesType: string; +}; + +const apiVersion = "2025-04-15"; + +const handleResponse = async (response: Response, context: string) => { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`Failed to fetch ${context}. Status: ${response.status}. ${body || ""}`); + } + return response.json(); +}; + +export const fetchRoleAssignments = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + principalId: string, +): Promise => { + const uri = buildArmUrl( + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments`, + apiVersion, + ); + + const response = await fetch(uri, { method: "GET", headers: getCopyJobAuthorizationHeader() }); + const data = await handleResponse(response, "role assignments"); + + return (data.value || []).filter( + (assignment: RoleAssignmentType) => assignment?.properties?.principalId === principalId, + ); +}; + +export const fetchRoleDefinitions = async (roleAssignments: RoleAssignmentType[]): Promise => { + const roleDefinitionIds = roleAssignments.map((assignment) => assignment.properties.roleDefinitionId); + const uniqueRoleDefinitionIds = Array.from(new Set(roleDefinitionIds)); + + const headers = getCopyJobAuthorizationHeader(); + const roleDefinitionUris = uniqueRoleDefinitionIds.map((id) => buildArmUrl(id, apiVersion)); + + const promises = roleDefinitionUris.map((url) => fetch(url, { method: "GET", headers })); + const responses = await Promise.all(promises); + + const roleDefinitions = await Promise.all( + responses.map((res, i) => handleResponse(res, `role definition ${uniqueRoleDefinitionIds[i]}`)), + ); + + return roleDefinitions; +}; + +export const assignRole = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + principalId: string, +): Promise => { + if (!principalId) { + return null; + } + const accountScope = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`; + const roleDefinitionId = `${accountScope}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000001`; + const roleAssignmentName = crypto.randomUUID(); + const path = `${accountScope}/sqlRoleAssignments/${roleAssignmentName}`; + + const body = { + properties: { + roleDefinitionId, + scope: `${accountScope}/`, + principalId, + }, + }; + const response: RoleAssignmentType = await armRequest({ + host: configContext.ARM_ENDPOINT, + path, + method: "PUT", + apiVersion, + body, + }); + return response; +}; diff --git a/src/Utils/arm/armUtils.ts b/src/Utils/arm/armUtils.ts new file mode 100644 index 000000000..3c2d0c356 --- /dev/null +++ b/src/Utils/arm/armUtils.ts @@ -0,0 +1,15 @@ +import { configContext } from "ConfigContext"; + +const getArmBaseUrl = (): string => { + const base = configContext.ARM_ENDPOINT; + return base.endsWith("/") ? base.slice(0, -1) : base; +}; + +const buildArmUrl = (path: string, apiVersion: string): string => { + if (!path || !apiVersion) { + return ""; + } + return `${getArmBaseUrl()}${path}?api-version=${apiVersion}`; +}; + +export { buildArmUrl, getArmBaseUrl }; diff --git a/src/Utils/arm/databaseAccountUtils.ts b/src/Utils/arm/databaseAccountUtils.ts new file mode 100644 index 000000000..c7eb756b0 --- /dev/null +++ b/src/Utils/arm/databaseAccountUtils.ts @@ -0,0 +1,36 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { userContext } from "UserContext"; +import { buildArmUrl } from "Utils/arm/armUtils"; + +const apiVersion = "2025-04-15"; +export type FetchAccountDetailsParams = { + subscriptionId: string; + resourceGroupName: string; + accountName: string; +}; + +const buildUrl = (params: FetchAccountDetailsParams): string => { + const { subscriptionId, resourceGroupName, accountName } = params; + + return buildArmUrl( + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`, + apiVersion, + ); +}; + +export async function fetchDatabaseAccount(subscriptionId: string, resourceGroupName: string, accountName: string) { + if (!userContext.authorizationToken) { + return Promise.reject("Authorization token is missing"); + } + const headers = new Headers(); + headers.append("Authorization", userContext.authorizationToken); + headers.append("Content-Type", "application/json"); + const uri = buildUrl({ subscriptionId, resourceGroupName, accountName }); + const response = await fetch(uri, { method: "GET", headers: headers }); + + if (!response.ok) { + throw new Error(`Error fetching database account: ${response.statusText}`); + } + const account: DatabaseAccount = await response.json(); + return account; +} diff --git a/src/Utils/arm/generatedClients/dataTransferService/dataTransferJobs.ts b/src/Utils/arm/generatedClients/dataTransferService/dataTransferJobs.ts index 0c2b7c916..a6168aba1 100644 --- a/src/Utils/arm/generatedClients/dataTransferService/dataTransferJobs.ts +++ b/src/Utils/arm/generatedClients/dataTransferService/dataTransferJobs.ts @@ -3,13 +3,13 @@ Run "npm run generateARMClients" to regenerate Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs - Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2023-11-15-preview/dataTransferService.json + Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-05-01-preview/dataTransferService.json */ import { configContext } from "../../../../ConfigContext"; import { armRequest } from "../../request"; import * as Types from "./types"; -const apiVersion = "2023-11-15-preview"; +const apiVersion = "2025-05-01-preview"; /* Creates a Data Transfer Job. */ export async function create( @@ -67,12 +67,24 @@ export async function cancel( return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "POST", apiVersion }); } +/* Completes a Data Transfer Online Job. */ +export async function complete( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise { + const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs/${jobName}/complete`; + return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "POST", apiVersion }); +} + /* Get a list of Data Transfer jobs. */ export async function listByDatabaseAccount( subscriptionId: string, resourceGroupName: string, accountName: string, + signal?: AbortSignal, ): Promise { const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`; - return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion }); + return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion, signal }); } diff --git a/src/Utils/arm/generatedClients/dataTransferService/types.ts b/src/Utils/arm/generatedClients/dataTransferService/types.ts index 27c3db709..8807b6873 100644 --- a/src/Utils/arm/generatedClients/dataTransferService/types.ts +++ b/src/Utils/arm/generatedClients/dataTransferService/types.ts @@ -3,13 +3,13 @@ Run "npm run generateARMClients" to regenerate Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs - Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2023-11-15-preview/dataTransferService.json + Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-05-01-preview/dataTransferService.json */ /* Base class for all DataTransfer source/sink */ export interface DataTransferDataSourceSink { /* undocumented */ - component: "CosmosDBCassandra" | "CosmosDBMongo" | "CosmosDBSql" | "AzureBlobStorage"; + component: "CosmosDBCassandra" | "CosmosDBMongo" | "CosmosDBMongoVCore" | "CosmosDBSql" | "AzureBlobStorage"; } /* A base CosmosDB data source/sink */ @@ -34,6 +34,18 @@ export type CosmosMongoDataTransferDataSourceSink = BaseCosmosDataTransferDataSo collectionName: string; }; +/* A CosmosDB Mongo vCore API data source/sink */ +export type CosmosMongoVCoreDataTransferDataSourceSink = DataTransferDataSourceSink & { + /* undocumented */ + databaseName: string; + /* undocumented */ + collectionName: string; + /* undocumented */ + hostName?: string; + /* undocumented */ + connectionStringKeyVaultUri?: string; +}; + /* A CosmosDB No Sql API data source/sink */ export type CosmosSqlDataTransferDataSourceSink = BaseCosmosDataTransferDataSourceSink & { /* undocumented */ diff --git a/src/Utils/arm/identityUtils.ts b/src/Utils/arm/identityUtils.ts new file mode 100644 index 000000000..48700ea1f --- /dev/null +++ b/src/Utils/arm/identityUtils.ts @@ -0,0 +1,57 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { configContext } from "../../ConfigContext"; +import { fetchDatabaseAccount } from "./databaseAccountUtils"; +import { armRequest } from "./request"; + +const apiVersion = "2025-04-15"; + +const updateIdentity = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + body: object, +): Promise => { + const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`; + const response: { status: string } = await armRequest({ + host: configContext.ARM_ENDPOINT, + path, + method: "PATCH", + apiVersion, + body, + }); + if (response.status === "Succeeded") { + const account = await fetchDatabaseAccount(subscriptionId, resourceGroupName, accountName); + return account; + } + return null; +}; + +const updateSystemIdentity = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, +): Promise => { + const body = { + identity: { + type: "SystemAssigned", + }, + }; + const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body); + return updatedAccount; +}; + +const updateDefaultIdentity = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, +): Promise => { + const body = { + properties: { + defaultIdentity: "SystemAssignedIdentity", + }, + }; + const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body); + return updatedAccount; +}; + +export { updateDefaultIdentity, updateSystemIdentity }; diff --git a/src/Utils/arm/request.ts b/src/Utils/arm/request.ts index 5e3ebb004..3471fb67b 100644 --- a/src/Utils/arm/request.ts +++ b/src/Utils/arm/request.ts @@ -48,6 +48,7 @@ interface Options { queryParams?: ARMQueryParams; contentType?: string; customHeaders?: Record; + signal?: AbortSignal; } export async function armRequestWithoutPolling({ @@ -59,6 +60,7 @@ export async function armRequestWithoutPolling({ queryParams, contentType, customHeaders, + signal, }: Options): Promise<{ result: T; operationStatusUrl: string }> { const url = new URL(path, host); url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion); @@ -81,6 +83,7 @@ export async function armRequestWithoutPolling({ method, headers, body: requestBody ? JSON.stringify(requestBody) : undefined, + signal, }); if (!response.ok) { @@ -116,6 +119,7 @@ export async function armRequest({ queryParams, contentType, customHeaders, + signal, }: Options): Promise { const armRequestResult = await armRequestWithoutPolling({ host, @@ -126,6 +130,7 @@ export async function armRequest({ queryParams, contentType, customHeaders, + signal, }); const operationStatusUrl = armRequestResult.operationStatusUrl; if (operationStatusUrl) { diff --git a/src/hooks/useDataContainers.tsx b/src/hooks/useDataContainers.tsx new file mode 100644 index 000000000..ecd6e55e1 --- /dev/null +++ b/src/hooks/useDataContainers.tsx @@ -0,0 +1,75 @@ +import { DatabaseModel } from "Contracts/DataModels"; +import useSWR from "swr"; +import { getCollectionEndpoint, getDatabaseEndpoint } from "../Common/DatabaseAccountUtility"; +import { configContext } from "../ConfigContext"; +import { ApiType } from "../UserContext"; +import { getCopyJobAuthorizationHeader } from "../Utils/CopyJobAuthUtils"; + +const apiVersion = "2023-09-15"; +export interface FetchDataContainersListParams { + subscriptionId: string; + resourceGroupName: string; + databaseName: string; + accountName: string; + apiType?: ApiType; +} + +const buildReadDataContainersListUrl = (params: FetchDataContainersListParams): string => { + const { subscriptionId, resourceGroupName, accountName, databaseName, apiType } = params; + const databaseEndpoint = getDatabaseEndpoint(apiType); + const collectionEndpoint = getCollectionEndpoint(apiType); + + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/${databaseEndpoint}/${databaseName}/${collectionEndpoint}?api-version=${apiVersion}`; +}; + +const fetchDataContainersList = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + databaseName: string, + apiType: ApiType, +): Promise => { + const uri = buildReadDataContainersListUrl({ + subscriptionId, + resourceGroupName, + accountName, + databaseName, + apiType, + }); + const headers = getCopyJobAuthorizationHeader(); + + const response = await fetch(uri, { + method: "GET", + headers: headers, + }); + + if (!response.ok) { + throw new Error("Failed to fetch containers"); + } + + const data = await response.json(); + return data.value; +}; + +export function useDataContainers( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + databaseName: string, + apiType: ApiType, +): DatabaseModel[] | undefined { + const { data } = useSWR( + () => + subscriptionId && resourceGroupName && accountName && databaseName && apiType + ? ["fetchContainersLinkedToDatabases", subscriptionId, resourceGroupName, accountName, databaseName, apiType] + : undefined, + (_, subscriptionId, resourceGroupName, accountName, databaseName, apiType) => + fetchDataContainersList(subscriptionId, resourceGroupName, accountName, databaseName, apiType), + ); + + return data; +} diff --git a/src/hooks/useDatabaseAccounts.tsx b/src/hooks/useDatabaseAccounts.tsx index f517b2e30..18474b6fc 100644 --- a/src/hooks/useDatabaseAccounts.tsx +++ b/src/hooks/useDatabaseAccounts.tsx @@ -1,6 +1,7 @@ import { HttpHeaders } from "Common/Constants"; import { QueryRequestOptions, QueryResponse } from "Contracts/AzureResourceGraph"; import useSWR from "swr"; +import { userContext } from "UserContext"; import { configContext } from "../ConfigContext"; import { DatabaseAccount } from "../Contracts/DataModels"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -10,9 +11,15 @@ interface AccountListResult { value: DatabaseAccount[]; } -export async function fetchDatabaseAccounts(subscriptionId: string, accessToken: string): Promise { +export async function fetchDatabaseAccounts( + subscriptionId: string, + accessToken: string = "", +): Promise { + if (!accessToken && !userContext.authorizationToken) { + return []; + } const headers = new Headers(); - const bearer = `Bearer ${accessToken}`; + const bearer = accessToken ? `Bearer ${accessToken}` : userContext.authorizationToken; headers.append("Authorization", bearer); @@ -35,10 +42,13 @@ export async function fetchDatabaseAccounts(subscriptionId: string, accessToken: export async function fetchDatabaseAccountsFromGraph( subscriptionId: string, - accessToken: string, + accessToken: string = "", ): Promise { + if (!accessToken && !userContext.authorizationToken) { + return []; + } const headers = new Headers(); - const bearer = `Bearer ${accessToken}`; + const bearer = accessToken ? `Bearer ${accessToken}` : userContext.authorizationToken; headers.append("Authorization", bearer); headers.append(HttpHeaders.contentType, "application/json"); @@ -85,9 +95,9 @@ export async function fetchDatabaseAccountsFromGraph( return databaseAccounts.sort((a, b) => a.name.localeCompare(b.name)); } -export function useDatabaseAccounts(subscriptionId: string, armToken: string): DatabaseAccount[] | undefined { +export function useDatabaseAccounts(subscriptionId: string, armToken: string = ""): DatabaseAccount[] | undefined { const { data } = useSWR( - () => (armToken && subscriptionId ? ["databaseAccounts", subscriptionId, armToken] : undefined), + () => (subscriptionId ? ["databaseAccounts", subscriptionId, armToken] : undefined), (_, subscriptionId, armToken) => fetchDatabaseAccountsFromGraph(subscriptionId, armToken), ); return data; diff --git a/src/hooks/useDatabases.tsx b/src/hooks/useDatabases.tsx new file mode 100644 index 000000000..c0d77b8e0 --- /dev/null +++ b/src/hooks/useDatabases.tsx @@ -0,0 +1,65 @@ +import { DatabaseModel } from "Contracts/DataModels"; +import useSWR from "swr"; +import { getDatabaseEndpoint } from "../Common/DatabaseAccountUtility"; +import { configContext } from "../ConfigContext"; +import { ApiType } from "../UserContext"; +import { getCopyJobAuthorizationHeader } from "../Utils/CopyJobAuthUtils"; + +const apiVersion = "2023-09-15"; +export interface FetchDatabasesListParams { + subscriptionId: string; + resourceGroupName: string; + accountName: string; + apiType?: ApiType; +} + +const buildReadDatabasesListUrl = (params: FetchDatabasesListParams): string => { + const { subscriptionId, resourceGroupName, accountName, apiType } = params; + const databaseEndpoint = getDatabaseEndpoint(apiType); + + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/${databaseEndpoint}?api-version=${apiVersion}`; +}; + +const fetchDatabasesList = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + apiType: ApiType, +): Promise => { + const uri = buildReadDatabasesListUrl({ subscriptionId, resourceGroupName, accountName, apiType }); + const headers = getCopyJobAuthorizationHeader(); + + const response = await fetch(uri, { + method: "GET", + headers: headers, + }); + + if (!response.ok) { + throw new Error("Failed to fetch databases"); + } + + const data = await response.json(); + return data.value; +}; + +export function useDatabases( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + apiType: ApiType, +): DatabaseModel[] | undefined { + const { data } = useSWR( + () => + subscriptionId && resourceGroupName && accountName && apiType + ? ["fetchDatabasesLinkedToResource", subscriptionId, resourceGroupName, accountName, apiType] + : undefined, + (_, subscriptionId, resourceGroupName, accountName, apiType) => + fetchDatabasesList(subscriptionId, resourceGroupName, accountName, apiType), + ); + + return data; +} diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 46e655a87..3e5546f80 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -960,6 +960,10 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { Object.assign(userContext.features, extractFeatures(new URLSearchParams(inputs.features))); } + if (configContext.platform === Platform.Portal && inputs.containerCopyEnabled && userContext.apiType === "SQL") { + Object.assign(userContext.features, { enableContainerCopy: inputs.containerCopyEnabled }); + } + if (inputs.flights) { if (inputs.flights.indexOf(Flights.AutoscaleTest) !== -1) { userContext.features.autoscaleDefault; diff --git a/src/hooks/useSidePanel.ts b/src/hooks/useSidePanel.ts index 8f7eab69b..25b87f346 100644 --- a/src/hooks/useSidePanel.ts +++ b/src/hooks/useSidePanel.ts @@ -3,15 +3,19 @@ import create, { UseStore } from "zustand"; export interface SidePanelState { isOpen: boolean; panelWidth: string; + hasConsole: boolean; panelContent?: JSX.Element; headerText?: string; openSidePanel: (headerText: string, panelContent: JSX.Element, panelWidth?: string, onClose?: () => void) => void; closeSidePanel: () => void; + setPanelHasConsole: (hasConsole: boolean) => void; getRef?: React.RefObject; // Optional ref for focusing the last element. } export const useSidePanel: UseStore = create((set) => ({ isOpen: false, panelWidth: "440px", + hasConsole: true, + setPanelHasConsole: (hasConsole: boolean) => set((state) => ({ ...state, hasConsole })), openSidePanel: (headerText, panelContent, panelWidth = "440px") => set((state) => ({ ...state, headerText, panelContent, panelWidth, isOpen: true })), closeSidePanel: () => { diff --git a/src/hooks/useSubscriptions.tsx b/src/hooks/useSubscriptions.tsx index ca80a87f5..5977ac911 100644 --- a/src/hooks/useSubscriptions.tsx +++ b/src/hooks/useSubscriptions.tsx @@ -1,6 +1,7 @@ import { HttpHeaders } from "Common/Constants"; import { QueryRequestOptions, QueryResponse } from "Contracts/AzureResourceGraph"; import useSWR from "swr"; +import { userContext } from "UserContext"; import { configContext } from "../ConfigContext"; import { Subscription } from "../Contracts/DataModels"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -10,9 +11,12 @@ interface SubscriptionListResult { value: Subscription[]; } -export async function fetchSubscriptions(accessToken: string): Promise { +export async function fetchSubscriptions(accessToken: string = ""): Promise { + if (!accessToken && !userContext.authorizationToken) { + return []; + } const headers = new Headers(); - const bearer = `Bearer ${accessToken}`; + const bearer = accessToken ? `Bearer ${accessToken}` : userContext.authorizationToken; headers.append("Authorization", bearer); @@ -35,9 +39,12 @@ export async function fetchSubscriptions(accessToken: string): Promise a.displayName.localeCompare(b.displayName)); } -export async function fetchSubscriptionsFromGraph(accessToken: string): Promise { +export async function fetchSubscriptionsFromGraph(accessToken: string = ""): Promise { + if (!accessToken && !userContext.authorizationToken) { + return []; + } const headers = new Headers(); - const bearer = `Bearer ${accessToken}`; + const bearer = accessToken ? `Bearer ${accessToken}` : userContext.authorizationToken; headers.append("Authorization", bearer); headers.append(HttpHeaders.contentType, "application/json"); @@ -85,9 +92,9 @@ export async function fetchSubscriptionsFromGraph(accessToken: string): Promise< return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName)); } -export function useSubscriptions(armToken: string): Subscription[] | undefined { +export function useSubscriptions(armToken: string = ""): Subscription[] | undefined { const { data } = useSWR( - () => (armToken ? ["subscriptions", armToken] : undefined), + () => ["subscriptions", armToken], (_, armToken) => fetchSubscriptionsFromGraph(armToken), ); return data;