mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-23 10:51:30 +00:00
Compare commits
12 Commits
users/aisa
...
pixelCorre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa68a9b00 | ||
|
|
844b6e6b65 | ||
|
|
58e187aeb2 | ||
|
|
5ba7ce2f10 | ||
|
|
e002a4505c | ||
|
|
6483bd146d | ||
|
|
7b437b62ce | ||
|
|
c504d97f7c | ||
|
|
a23a7791d4 | ||
|
|
9bfb6aecc9 | ||
|
|
9227ad379b | ||
|
|
c83f4fc431 |
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -24,5 +24,14 @@
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
17
images/ContainerCopy/copy-jobs.svg
Normal file
17
images/ContainerCopy/copy-jobs.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg width="96" height="104" viewBox="0 0 96 104" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.2" d="M80.5008 81.2203L41.2637 58.2012L35.7705 61.9941L74.6152 84.6208L80.5008 81.2203Z" fill="#AAAAAA"/>
|
||||
<path opacity="0.2" d="M60.2283 92.5992L20.9912 69.5801L15.498 73.373L54.3428 95.9997L60.2283 92.5992Z" fill="#AAAAAA"/>
|
||||
<path d="M63.7596 30.9969L74.8768 37.4057L74.746 82.1359L35.7705 59.7708L35.9013 3.00781L63.7596 19.095V30.9969Z" fill="#C9C9C9"/>
|
||||
<path d="M35.9014 3.00818L41.0022 0L68.8605 16.0872L63.7597 19.0954L35.9014 3.00818Z" fill="#AAAAAA"/>
|
||||
<path d="M74.8769 37.4067L79.9777 34.5293L79.8469 79.2596L74.7461 82.2677L74.8769 37.4067Z" fill="#AAAAAA"/>
|
||||
<path d="M43.4872 42.245L54.6043 48.6537L54.4735 93.384L15.498 71.0188L15.6288 14.2559L43.4872 30.3431V42.245Z" fill="#F4F4F4"/>
|
||||
<path d="M15.6289 14.2562L20.7297 11.248L48.5881 27.3352L43.4872 30.3434L15.6289 14.2562Z" fill="#DCDCDC"/>
|
||||
<path d="M54.6044 48.6547L59.7052 45.7773L59.5745 90.5076L54.4736 93.5158L54.6044 48.6547Z" fill="#DCDCDC"/>
|
||||
<path d="M63.7598 19.0961L68.8606 16.0879L79.9778 34.5293L74.8769 37.4067L63.7598 19.0961Z" fill="#C9C9C9"/>
|
||||
<path d="M63.7598 19.0957L74.8769 37.4063L63.7598 30.9976V19.0957Z" fill="#DCDCDC"/>
|
||||
<path d="M43.4873 30.3441L48.5881 27.3359L59.7053 45.7774L54.6045 48.6548L43.4873 30.3441Z" fill="#F4F4F4"/>
|
||||
<path d="M43.4873 30.3438L54.6045 48.6544L43.4873 42.2457V30.3438Z" fill="#C9C9C9"/>
|
||||
<path d="M46.8751 52.4595V55.9693L23.2275 42.1367V38.627L46.8751 52.4595Z" fill="#C9C9C9"/>
|
||||
<path d="M46.8751 59.0658V62.5756L23.2275 48.6914V45.1816L46.8751 59.0658Z" fill="#C9C9C9"/>
|
||||
<path d="M46.8751 65.3621V68.8719L23.2275 54.9877V51.6328L46.8751 65.3621Z" fill="#C9C9C9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
40
src/Common/ShimmerTree/index.tsx
Normal file
40
src/Common/ShimmerTree/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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) => {
|
||||
/**
|
||||
* indentLevels - Array of indent levels for shimmer tree
|
||||
* 0 - Root
|
||||
* 1 - Level 1
|
||||
* 2 - Level 2
|
||||
* 3 - Level 3
|
||||
* n - Level n
|
||||
* */
|
||||
const renderShimmers = (indent: IndentLevel) => (
|
||||
<Shimmer
|
||||
key={Math.random()}
|
||||
shimmerElements={[
|
||||
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` }, // Indent for hierarchy
|
||||
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
|
||||
]}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 8 }} style={{ width: "50%", ...style }} data-testid="shimmer-stack">
|
||||
{indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShimmerTree;
|
||||
@@ -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;
|
||||
|
||||
@@ -444,6 +444,7 @@ export interface DataExplorerInputsFrame {
|
||||
};
|
||||
feedbackPolicies?: any;
|
||||
aadToken?: string;
|
||||
containerCopyEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelfServeFrameInputs {
|
||||
|
||||
186
src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx
Normal file
186
src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
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";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types";
|
||||
|
||||
export const openCreateCopyJobPanel = () => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
||||
<CreateCopyJobScreensProvider />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
|
||||
let copyJobsAbortController: AbortController | null = null;
|
||||
|
||||
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
// Abort previous request if still in-flight
|
||||
if (copyJobsAbortController) {
|
||||
copyJobsAbortController.abort();
|
||||
}
|
||||
copyJobsAbortController = new AbortController();
|
||||
try {
|
||||
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,
|
||||
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);
|
||||
console.error(`Error fetching copy jobs: ${errorContent}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => {
|
||||
try {
|
||||
const { source, target, migrationType, jobName } = state;
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const body = {
|
||||
properties: {
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
remoteAccountName: source?.account?.name,
|
||||
databaseName: source?.databaseId,
|
||||
containerName: source?.containerId,
|
||||
},
|
||||
destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: target?.databaseId,
|
||||
containerName: target?.containerId,
|
||||
},
|
||||
mode: migrationType,
|
||||
},
|
||||
} as unknown as CreateJobRequest;
|
||||
|
||||
const response = await create(subscriptionId, resourceGroup, accountName, jobName, body);
|
||||
MonitorCopyJobsRefState.getState().ref?.refreshJobList();
|
||||
onSuccess();
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error submitting create copy job:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobGetResults> => {
|
||||
try {
|
||||
let updateFn = null;
|
||||
switch (action.toLowerCase()) {
|
||||
case CopyJobActions.pause:
|
||||
updateFn = pause;
|
||||
break;
|
||||
case CopyJobActions.resume:
|
||||
updateFn = resume;
|
||||
break;
|
||||
case CopyJobActions.cancel:
|
||||
updateFn = cancel;
|
||||
break;
|
||||
case CopyJobActions.complete:
|
||||
updateFn = complete;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error);
|
||||
|
||||
const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
|
||||
const pattern = new RegExp(`'(${statusList.join("|")})'`, "g");
|
||||
const normalizedErrorMessage = errorMessage.replace(
|
||||
pattern,
|
||||
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
|
||||
);
|
||||
|
||||
console.error(`Error updating copy job status: ${normalizedErrorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
31
src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx
Normal file
31
src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { StyleConstants } from "../../../Common/StyleConstants";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
|
||||
import { ContainerCopyProps } from "../Types";
|
||||
import { getCommandBarButtons } from "./Utils";
|
||||
|
||||
const backgroundColor = StyleConstants.BaseLight;
|
||||
const rootStyle = {
|
||||
root: {
|
||||
backgroundColor: backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
|
||||
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
|
||||
|
||||
return (
|
||||
<div className="commandBarContainer">
|
||||
<FluentCommandBar
|
||||
ariaLabel="Use left and right arrow keys to navigate between commands"
|
||||
styles={rootStyle}
|
||||
items={controlButtons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobCommandBar;
|
||||
58
src/Explorer/ContainerCopy/CommandBar/Utils.ts
Normal file
58
src/Explorer/ContainerCopy/CommandBar/Utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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";
|
||||
|
||||
function getCopyJobBtns(): 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: () => {},
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function getCommandBarButtons(_container: Explorer): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns().map(btnMapper);
|
||||
}
|
||||
132
src/Explorer/ContainerCopy/ContainerCopyMessages.ts
Normal file
132
src/Explorer/ContainerCopy/ContainerCopyMessages.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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",
|
||||
|
||||
// Create Copy Job Panel
|
||||
createCopyJobPanelTitle: "Copy container",
|
||||
|
||||
// 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:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
||||
},
|
||||
toggleBtn: {
|
||||
onText: "On",
|
||||
offText: "Off",
|
||||
},
|
||||
addManagedIdentity: {
|
||||
title: "System assigned managed identity enabled",
|
||||
description:
|
||||
"Enable a system assigned managed identity for the destination account to allow the copy job to access it.",
|
||||
toggleLabel: "System assigned managed identity",
|
||||
managedIdentityTooltip:
|
||||
"A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. 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.",
|
||||
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: (identityName: string) =>
|
||||
identityName
|
||||
? `'${identityName}' will be registered with Microsoft Entra ID. Once it is registered, '${identityName}' can be granted permissions to access resources protected by Microsoft Entra ID. Do you want to enable the system assigned managed identity for '${identityName}'?`
|
||||
: "",
|
||||
},
|
||||
defaultManagedIdentity: {
|
||||
title: "System assigned managed identity enabled as default",
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
tooltip:
|
||||
"A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. 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.",
|
||||
popoverTitle: "System assigned managed identity set as default",
|
||||
popoverDescription:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
|
||||
},
|
||||
readPermissionAssigned: {
|
||||
title: "Read permission assigned to default identity",
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
tooltip:
|
||||
"A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. 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.",
|
||||
popoverTitle: "Read permission assigned to default identity",
|
||||
popoverDescription:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
|
||||
},
|
||||
pointInTimeRestore: {
|
||||
title: "Point In Time Restore enabled",
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
buttonText: "Enable Point In Time Restore",
|
||||
},
|
||||
onlineCopyEnabled: {
|
||||
title: "Online copy enabled",
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
buttonText: "Enable Online Copy",
|
||||
},
|
||||
MonitorJobs: {
|
||||
Columns: {
|
||||
lastUpdatedTime: "Date & time",
|
||||
name: "Job name",
|
||||
status: "Status",
|
||||
completionPercentage: "Completion %",
|
||||
duration: "Duration",
|
||||
error: "Error message",
|
||||
mode: "Mode",
|
||||
actions: "Actions",
|
||||
},
|
||||
Actions: {
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
54
src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
Normal file
54
src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { CopyJobMigrationType } from "../Enums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types";
|
||||
|
||||
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
|
||||
export const useCopyJobContext = (): CopyJobContextProviderType => {
|
||||
const context = React.useContext(CopyJobContext);
|
||||
if (!context) {
|
||||
throw new Error("useCopyJobContext must be used within a CopyJobContextProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface CopyJobContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const getInitialCopyJobState = (): CopyJobContextState => {
|
||||
return {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: null,
|
||||
account: null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: userContext.subscriptionId || "",
|
||||
account: userContext.databaseAccount || null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
};
|
||||
};
|
||||
|
||||
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
|
||||
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
|
||||
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
|
||||
|
||||
const resetCopyJobState = () => {
|
||||
setCopyJobState(getInitialCopyJobState());
|
||||
};
|
||||
|
||||
return (
|
||||
<CopyJobContext.Provider value={{ copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
|
||||
{props.children}
|
||||
</CopyJobContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobContextProvider;
|
||||
116
src/Explorer/ContainerCopy/CopyJobUtils.ts
Normal file
116
src/Explorer/ContainerCopy/CopyJobUtils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobErrorType } from "./Types";
|
||||
|
||||
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; // Return null for invalid format
|
||||
}
|
||||
const formatPart = (value: number, unit: string) => {
|
||||
if (unit === "seconds") {
|
||||
value = Math.round(value);
|
||||
}
|
||||
return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : "";
|
||||
};
|
||||
|
||||
const [hours, minutes, seconds] = timeParts;
|
||||
const formattedTimeParts = [
|
||||
formatPart(hours, "hours"),
|
||||
formatPart(minutes, "minutes"),
|
||||
formatPart(seconds, "seconds"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return formattedTimeParts || "0 seconds"; // Return "0 seconds" if all parts are zero
|
||||
}
|
||||
|
||||
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
|
||||
const date = new Date(utcStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formattedDateTime: new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(date),
|
||||
timestamp: date.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToCamelCase(str: string): string {
|
||||
const formattedStr = str
|
||||
.split(/\s+/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
return formattedStr;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType {
|
||||
return {
|
||||
...error,
|
||||
message: error.message.split("\r\n\r\n")[0],
|
||||
};
|
||||
}
|
||||
|
||||
export function getAccountDetailsFromResourceId(accountId: string | undefined) {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
const pattern = new RegExp(
|
||||
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB/databaseAccounts/([^/]+)",
|
||||
"i",
|
||||
);
|
||||
const matches = accountId.match(pattern);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
|
||||
return { subscriptionId, resourceGroup, accountName };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React, { useMemo } from "react";
|
||||
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink } from "../../../CopyJobUtils";
|
||||
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.managedIdentityTooltip;
|
||||
const userAssignedTooltip = ContainerCopyMessages.addManagedIdentity.userAssignedIdentityTooltip;
|
||||
|
||||
const textStyle = { display: "flex", alignItems: "center" };
|
||||
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const [systemAssigned, onToggle] = useToggle(false);
|
||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
|
||||
|
||||
const manageIdentityLink = useMemo(() => {
|
||||
const { target } = copyJobState;
|
||||
const resourceUri = buildResourceLink(target.account);
|
||||
return target?.account?.id ? `${resourceUri}/ManagedIdentitiesBlade` : "#";
|
||||
}, [copyJobState]);
|
||||
|
||||
return (
|
||||
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Toggle
|
||||
label={
|
||||
<Text className="toggle-label" style={textStyle}>
|
||||
{ContainerCopyMessages.addManagedIdentity.toggleLabel}
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</Text>
|
||||
}
|
||||
checked={systemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<Text className="user-assigned-label" style={textStyle}>
|
||||
{ContainerCopyMessages.addManagedIdentity.userAssignedIdentityLabel}
|
||||
<InfoTooltip content={userAssignedTooltip} />
|
||||
</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Link href={manageIdentityLink} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.createUserAssignedIdentityLink}
|
||||
</Link>
|
||||
</div>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={systemAssigned}
|
||||
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddManagedIdentity;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Stack, 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;
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddReadPermissionToDefaultIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const [readPermissionAssigned, onToggle] = useToggle(false);
|
||||
|
||||
const handleAddReadPermission = useCallback(async () => {
|
||||
const { source, target } = copyJobState;
|
||||
const selectedSourceAccount = source?.account;
|
||||
try {
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
setLoading(true);
|
||||
const assignedRole = await assignRole(
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
target?.account?.identity?.principalId ?? "",
|
||||
);
|
||||
if (assignedRole) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
sourceReadAccessFromTarget: true,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error assigning read permission to default identity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [copyJobState, setCopyJobState]);
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">
|
||||
{ContainerCopyMessages.readPermissionAssigned.description}
|
||||
<InfoTooltip content={TooltipContent} />
|
||||
</div>
|
||||
<Toggle
|
||||
checked={readPermissionAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
inlineLabel
|
||||
styles={{
|
||||
root: { marginTop: 8, marginBottom: 12 },
|
||||
label: { display: "none" },
|
||||
}}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={readPermissionAssigned}
|
||||
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddReadPermission}
|
||||
>
|
||||
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddReadPermissionToDefaultIdentity;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Stack, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
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;
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const [defaultSystemAssigned, onToggle] = useToggle(false);
|
||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">
|
||||
{ContainerCopyMessages.defaultManagedIdentity.description}
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</div>
|
||||
<Toggle
|
||||
checked={defaultSystemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
inlineLabel
|
||||
styles={{
|
||||
root: { marginTop: 8, marginBottom: 12 },
|
||||
label: { display: "none" },
|
||||
}}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={defaultSystemAssigned}
|
||||
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultManagedIdentity;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PrimaryButton, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink } from "../../../CopyJobUtils";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor";
|
||||
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
const OnlineCopyEnabled: React.FC<AddManagedIdentityProps> = () => {
|
||||
const { copyJobState: { source } = {} } = useCopyJobContext();
|
||||
const sourceAccountLink = buildResourceLink(source?.account);
|
||||
const onlineCopyUrl = `${sourceAccountLink}/Features`;
|
||||
const onWindowClosed = () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Online copy window closed");
|
||||
};
|
||||
const openWindowAndMonitor = useWindowOpenMonitor(onlineCopyUrl, onWindowClosed);
|
||||
|
||||
return (
|
||||
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">{ContainerCopyMessages.onlineCopyEnabled.description}</div>
|
||||
<PrimaryButton text={ContainerCopyMessages.onlineCopyEnabled.buttonText} onClick={openWindowAndMonitor} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineCopyEnabled;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PrimaryButton, Stack } from "@fluentui/react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor";
|
||||
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
const PointInTimeRestore: React.FC<AddManagedIdentityProps> = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
||||
const sourceAccountLink = buildResourceLink(source?.account);
|
||||
const pitrUrl = `${sourceAccountLink}/backupRestore`;
|
||||
|
||||
const onWindowClosed = useCallback(async () => {
|
||||
try {
|
||||
const selectedSourceAccount = source?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
setLoading(true);
|
||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
||||
if (account) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
source: { ...prevState.source, account: account },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching database account after PITR window closed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const openWindowAndMonitor = useWindowOpenMonitor(pitrUrl, onWindowClosed);
|
||||
|
||||
return (
|
||||
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">{ContainerCopyMessages.pointInTimeRestore.description}</div>
|
||||
<PrimaryButton
|
||||
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
|
||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
disabled={loading}
|
||||
onClick={openWindowAndMonitor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointInTimeRestore;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
||||
|
||||
interface UseManagedIdentityUpdaterParams {
|
||||
updateIdentityFn: (
|
||||
subscriptionId: string,
|
||||
resourceGroup?: string,
|
||||
accountName?: string,
|
||||
) => Promise<DatabaseAccount | undefined>;
|
||||
}
|
||||
|
||||
interface UseManagedIdentityUpdaterReturn {
|
||||
loading: boolean;
|
||||
handleAddSystemIdentity: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useManagedIdentity = (
|
||||
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
|
||||
): UseManagedIdentityUpdaterReturn => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const selectedTargetAccount = copyJobState?.target?.account;
|
||||
const {
|
||||
subscriptionId: targetSubscriptionId,
|
||||
resourceGroup: targetResourceGroup,
|
||||
accountName: targetAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
||||
|
||||
const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName);
|
||||
if (updatedAccount) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
target: { ...prevState.target, account: updatedAccount },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error enabling system-assigned managed identity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [copyJobState, updateIdentityFn, setCopyJobState]);
|
||||
|
||||
return { loading, handleAddSystemIdentity };
|
||||
};
|
||||
|
||||
export default useManagedIdentity;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
||||
import { BackupPolicyType, CopyJobMigrationType, DefaultIdentityType, IdentityType } from "../../../../Enums";
|
||||
import { CopyJobContextState } from "../../../../Types";
|
||||
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
|
||||
import AddManagedIdentity from "../AddManagedIdentity";
|
||||
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
|
||||
import DefaultManagedIdentity from "../DefaultManagedIdentity";
|
||||
import OnlineCopyEnabled from "../OnlineCopyEnabled";
|
||||
import PointInTimeRestore from "../PointInTimeRestore";
|
||||
|
||||
export interface PermissionSectionConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
Component: React.ComponentType;
|
||||
disabled: boolean;
|
||||
completed?: boolean;
|
||||
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
// Section IDs for maintainability
|
||||
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,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
validate: (_state: CopyJobContextState) => {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
|
||||
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
|
||||
const isValidatingRef = useRef(false);
|
||||
|
||||
const sectionToValidate = useMemo(() => {
|
||||
const baseSections = [...PERMISSION_SECTIONS_CONFIG];
|
||||
if (state.migrationType === CopyJobMigrationType.Online) {
|
||||
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
|
||||
}
|
||||
return baseSections;
|
||||
}, [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];
|
||||
|
||||
// Check if this section was already validated and passed
|
||||
if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) {
|
||||
result.push({ ...section, completed: true });
|
||||
continue;
|
||||
}
|
||||
// We've reached the first non-cached section - validate it
|
||||
if (section.validate) {
|
||||
const isValid = await section.validate(state);
|
||||
newValidationCache.set(section.id, isValid);
|
||||
result.push({ ...section, completed: isValid });
|
||||
// Stop validation if current section failed
|
||||
if (!isValid) {
|
||||
for (let j = i + 1; j < sectionToValidate.length; j++) {
|
||||
result.push({ ...sectionToValidate[j], completed: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Section has no validate method
|
||||
newValidationCache.set(section.id, false);
|
||||
result.push({ ...section, completed: false });
|
||||
}
|
||||
}
|
||||
|
||||
setValidationCache(newValidationCache);
|
||||
setPermissionSections(result);
|
||||
isValidatingRef.current = false;
|
||||
};
|
||||
|
||||
validateSections();
|
||||
|
||||
return () => {
|
||||
isValidatingRef.current = false;
|
||||
};
|
||||
}, [state, sectionToValidate]);
|
||||
|
||||
return permissionSections ?? [];
|
||||
};
|
||||
|
||||
export default usePermissionSections;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const useToggle = (initialState = false) => {
|
||||
const [state, setState] = useState<boolean>(initialState);
|
||||
const onToggle = useCallback((_, checked?: boolean) => {
|
||||
setState(!!checked);
|
||||
}, []);
|
||||
return [state, onToggle] as const;
|
||||
};
|
||||
|
||||
export default useToggle;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const useWindowOpenMonitor = (url: string, onClose?: () => void, intervalMs = 500) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const openWindowAndMonitor = () => {
|
||||
const newWindow = window.open(url, "_blank");
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (newWindow?.closed) {
|
||||
clearInterval(intervalRef.current!);
|
||||
intervalRef.current = null;
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return openWindowAndMonitor;
|
||||
};
|
||||
|
||||
export default useWindowOpenMonitor;
|
||||
@@ -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";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums";
|
||||
import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
|
||||
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
|
||||
<AccordionItem key={id} value={id} disabled={disabled}>
|
||||
<AccordionHeader className="accordionHeader">
|
||||
<Text className="accordionHeaderText" variant="medium">
|
||||
{title}
|
||||
</Text>
|
||||
<Image
|
||||
className="statusIcon"
|
||||
src={completed ? CheckmarkIcon : WarningIcon}
|
||||
alt={completed ? "Checkmark icon" : "Warning icon"}
|
||||
width={completed ? 20 : 24}
|
||||
height={completed ? 20 : 24}
|
||||
/>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
|
||||
<Component />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
const AssignPermissions = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const permissionSections = usePermissionSections(copyJobState);
|
||||
const [openItems, setOpenItems] = React.useState<string[]>([]);
|
||||
|
||||
const indentLevels = React.useMemo<IndentLevel[]>(
|
||||
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const firstIncompleteSection = permissionSections.find((section) => !section.completed);
|
||||
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
|
||||
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
|
||||
setOpenItems(nextOpenItems);
|
||||
}
|
||||
}, [permissionSections]);
|
||||
|
||||
return (
|
||||
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
|
||||
<span>{ContainerCopyMessages.assignPermissions.description}</span>
|
||||
{permissionSections?.length === 0 ? (
|
||||
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
|
||||
) : (
|
||||
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
|
||||
{permissionSections.map((section) => (
|
||||
<PermissionSection key={section.id} {...section} />
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignPermissions;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
interface FieldRowProps {
|
||||
label?: string;
|
||||
children: React.ReactNode;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const FieldRow: React.FC<FieldRowProps> = ({ label = "", children, labelClassName = "" }) => {
|
||||
return (
|
||||
<Stack horizontal horizontalAlign="space-between" className="flex-row">
|
||||
{label && (
|
||||
<Stack.Item align="center" className="flex-fixed-width">
|
||||
<label className={`field-label ${labelClassName}`}>{label}: </label>
|
||||
</Stack.Item>
|
||||
)}
|
||||
<Stack.Item align="center" className="flex-grow-col">
|
||||
{children}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldRow;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import InfoIcon from "../../../../../../images/Info.svg";
|
||||
|
||||
const InfoTooltip: React.FC<{ content?: string }> = ({ content }) => {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const hostStyles: Partial<ITooltipHostStyles> = { root: { display: "inline-block" } };
|
||||
return (
|
||||
<TooltipHost content={content} calloutProps={{ gapSpace: 0 }} styles={hostStyles}>
|
||||
<Image src={InfoIcon} alt="Information" width={14} height={14} />
|
||||
</TooltipHost>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InfoTooltip);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
type NavigationControlsProps = {
|
||||
primaryBtnText: string;
|
||||
onPrimary: () => void;
|
||||
onPrevious: () => void;
|
||||
onCancel: () => void;
|
||||
isPrimaryDisabled: boolean;
|
||||
isPreviousDisabled: boolean;
|
||||
};
|
||||
|
||||
const NavigationControls: React.FC<NavigationControlsProps> = ({
|
||||
primaryBtnText,
|
||||
onPrimary,
|
||||
onPrevious,
|
||||
onCancel,
|
||||
isPrimaryDisabled,
|
||||
isPreviousDisabled,
|
||||
}) => (
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
|
||||
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
|
||||
<DefaultButton text="Cancel" onClick={onCancel} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default React.memo(NavigationControls);
|
||||
@@ -0,0 +1,67 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
interface PopoverContainerProps {
|
||||
isLoading?: boolean;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
onPrimary: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
|
||||
({ isLoading = false, title, children, onPrimary, onCancel }) => {
|
||||
return (
|
||||
<Stack
|
||||
className={`popover-container foreground ${isLoading ? "loading" : ""}`}
|
||||
tokens={{ childrenGap: 20 }}
|
||||
style={{ maxWidth: 450 }}
|
||||
>
|
||||
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text>{children}</Text>
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton
|
||||
text={isLoading ? "" : "Yes"}
|
||||
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
onClick={onPrimary}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface PopoverMessageProps {
|
||||
isLoading?: boolean;
|
||||
visible: boolean;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onPrimary: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PopoverMessage: React.FC<PopoverMessageProps> = ({
|
||||
isLoading = false,
|
||||
visible,
|
||||
title,
|
||||
onCancel,
|
||||
onPrimary,
|
||||
children,
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary} isLoading={isLoading}>
|
||||
{children}
|
||||
</PopoverContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopoverMessage;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { 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,
|
||||
} = useCopyJobNavigation();
|
||||
|
||||
return (
|
||||
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
|
||||
<Stack.Item className="createCopyJobScreensContent">{currentScreen?.component}</Stack.Item>
|
||||
<Stack.Item className="createCopyJobScreensFooter">
|
||||
<NavigationControls
|
||||
primaryBtnText={primaryBtnText}
|
||||
onPrimary={handlePrimary}
|
||||
onPrevious={handlePrevious}
|
||||
onCancel={handleCancel}
|
||||
isPrimaryDisabled={isPrimaryDisabled}
|
||||
isPreviousDisabled={isPreviousDisabled}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCopyJobScreens;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import CopyJobContextProvider from "../../Context/CopyJobContext";
|
||||
import CreateCopyJobScreens from "./CreateCopyJobScreens";
|
||||
|
||||
const CreateCopyJobScreensProvider = () => {
|
||||
return (
|
||||
<CopyJobContextProvider>
|
||||
<CreateCopyJobScreens />
|
||||
</CopyJobContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCopyJobScreensProvider;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IColumn } from "@fluentui/react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
const commonProps = {
|
||||
minWidth: 130,
|
||||
maxWidth: 140,
|
||||
styles: {
|
||||
root: {
|
||||
whiteSpace: "normal",
|
||||
lineHeight: "1.2",
|
||||
wordBreak: "break-word",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getPreviewCopyJobDetailsListColumns = (): IColumn[] => {
|
||||
return [
|
||||
{
|
||||
key: "sourcedbname",
|
||||
name: ContainerCopyMessages.sourceDatabaseLabel,
|
||||
fieldName: "sourceDatabaseName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "sourcecolname",
|
||||
name: ContainerCopyMessages.sourceContainerLabel,
|
||||
fieldName: "sourceContainerName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "targetdbname",
|
||||
name: ContainerCopyMessages.targetDatabaseLabel,
|
||||
fieldName: "targetDatabaseName",
|
||||
...commonProps,
|
||||
},
|
||||
{
|
||||
key: "targetcolname",
|
||||
name: ContainerCopyMessages.targetContainerLabel,
|
||||
fieldName: "targetContainerName",
|
||||
...commonProps,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
|
||||
import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
|
||||
|
||||
const PreviewCopyJob: React.FC = () => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
|
||||
const selectedDatabaseAndContainers = [
|
||||
{
|
||||
sourceDatabaseName: copyJobState.source?.databaseId,
|
||||
sourceContainerName: copyJobState.source?.containerId,
|
||||
targetDatabaseName: copyJobState.target?.databaseId,
|
||||
targetContainerName: copyJobState.target?.containerId,
|
||||
},
|
||||
];
|
||||
const jobName = copyJobState.jobName;
|
||||
|
||||
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
jobName: newValue || "",
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer">
|
||||
<FieldRow label={ContainerCopyMessages.jobNameLabel}>
|
||||
<TextField value={jobName} onChange={onJobNameChange} />
|
||||
</FieldRow>
|
||||
<Stack>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
|
||||
<Text>{copyJobState.source?.subscription?.displayName}</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text>{copyJobState.source?.account?.name}</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DetailsList
|
||||
items={selectedDatabaseAndContainers}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
checkboxVisibility={2}
|
||||
columns={getPreviewCopyJobDetailsListColumns()}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewCopyJob;
|
||||
@@ -0,0 +1,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";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface AccountDropdownProps {
|
||||
options: DropdownOptionType[];
|
||||
selectedKey?: string;
|
||||
disabled: boolean;
|
||||
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
|
||||
}
|
||||
|
||||
export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
|
||||
({ options, selectedKey, disabled, onChange }) => (
|
||||
<FieldRow label={ContainerCopyMessages.sourceAccountDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.sourceAccountDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.sourceAccountDropdownLabel}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
required
|
||||
selectedKey={selectedKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Checkbox, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
interface MigrationTypeCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
||||
}
|
||||
|
||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
|
||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow">
|
||||
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
|
||||
</Stack>
|
||||
));
|
||||
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Dropdown } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DropdownOptionType } from "../../../../Types";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface SubscriptionDropdownProps {
|
||||
options: DropdownOptionType[];
|
||||
selectedKey?: string;
|
||||
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
|
||||
}
|
||||
|
||||
export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.memo(
|
||||
({ options, selectedKey, onChange }) => (
|
||||
<FieldRow label={ContainerCopyMessages.subscriptionDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.subscriptionDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.subscriptionDropdownLabel}
|
||||
options={options}
|
||||
required
|
||||
selectedKey={selectedKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels";
|
||||
import { CopyJobMigrationType } from "../../../../Enums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types";
|
||||
|
||||
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, // reset on subscription change
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "account") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
account: data || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
},
|
||||
[setCopyJobState],
|
||||
);
|
||||
|
||||
const handleMigrationTypeChange = React.useCallback(
|
||||
(_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
},
|
||||
[setCopyJobState],
|
||||
);
|
||||
|
||||
return { handleSelectSourceAccount, handleMigrationTypeChange };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
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";
|
||||
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) => account.type === "SQL" || account.kind === "GlobalDocumentDB",
|
||||
);
|
||||
|
||||
const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
|
||||
const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
|
||||
|
||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
||||
|
||||
return (
|
||||
<Stack className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
||||
<span>{ContainerCopyMessages.selectAccountDescription}</span>
|
||||
|
||||
<SubscriptionDropdown
|
||||
options={subscriptionOptions}
|
||||
selectedKey={selectedSubscriptionId}
|
||||
onChange={(_ev, option) => handleSelectSourceAccount("subscription", option?.data)}
|
||||
/>
|
||||
|
||||
<AccountDropdown
|
||||
options={accountOptions}
|
||||
selectedKey={copyJobState?.source?.account?.id}
|
||||
disabled={!selectedSubscriptionId}
|
||||
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
|
||||
/>
|
||||
|
||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default SelectAccount;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { CopyJobContextState, DropdownOptionType } from "../../../../Types";
|
||||
|
||||
export function dropDownChangeHandler(setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>) {
|
||||
return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") =>
|
||||
(_evnt: React.FormEvent, option: DropdownOptionType) => {
|
||||
const value = option.key;
|
||||
setCopyJobState((prevState) => {
|
||||
switch (type) {
|
||||
case "sourceDatabase":
|
||||
return {
|
||||
...prevState,
|
||||
source: { ...prevState.source, databaseId: value, containerId: undefined },
|
||||
};
|
||||
case "sourceContainer":
|
||||
return {
|
||||
...prevState,
|
||||
source: { ...prevState.source, containerId: value },
|
||||
};
|
||||
case "targetDatabase":
|
||||
return {
|
||||
...prevState,
|
||||
target: { ...prevState.target, databaseId: value, containerId: undefined },
|
||||
};
|
||||
case "targetContainer":
|
||||
return {
|
||||
...prevState,
|
||||
target: { ...prevState.target, containerId: value },
|
||||
};
|
||||
default:
|
||||
return prevState;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Dropdown, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DatabaseContainerSectionProps } from "../../../../Types";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
export const DatabaseContainerSection = ({
|
||||
heading,
|
||||
databaseOptions,
|
||||
selectedDatabase,
|
||||
databaseDisabled,
|
||||
databaseOnChange,
|
||||
containerOptions,
|
||||
selectedContainer,
|
||||
containerDisabled,
|
||||
containerOnChange,
|
||||
}: DatabaseContainerSectionProps) => (
|
||||
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
|
||||
<label className="subHeading">{heading}</label>
|
||||
<FieldRow label={ContainerCopyMessages.databaseDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.databaseDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.databaseDropdownLabel}
|
||||
options={databaseOptions}
|
||||
required
|
||||
disabled={!!databaseDisabled}
|
||||
selectedKey={selectedDatabase}
|
||||
onChange={databaseOnChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
|
||||
options={containerOptions}
|
||||
required
|
||||
disabled={!!containerDisabled}
|
||||
selectedKey={selectedContainer}
|
||||
onChange={containerOnChange}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
|
||||
// Custom hooks
|
||||
const sourceDatabases = useDatabases(...sourceDbParams) || [];
|
||||
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
|
||||
const targetDatabases = useDatabases(...targetDbParams) || [];
|
||||
const targetContainers = useDataContainers(...targetContainerParams) || [];
|
||||
|
||||
// Memoize option objects for dropdowns
|
||||
const sourceDatabaseOptions = React.useMemo(
|
||||
() => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
||||
[sourceDatabases],
|
||||
);
|
||||
const sourceContainerOptions = React.useMemo(
|
||||
() => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
||||
[sourceContainers],
|
||||
);
|
||||
const targetDatabaseOptions = React.useMemo(
|
||||
() => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
||||
[targetDatabases],
|
||||
);
|
||||
const targetContainerOptions = React.useMemo(
|
||||
() => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
||||
[targetContainers],
|
||||
);
|
||||
|
||||
const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
|
||||
|
||||
return (
|
||||
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
|
||||
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.sourceContainerSubHeading}
|
||||
databaseOptions={sourceDatabaseOptions}
|
||||
selectedDatabase={source?.databaseId}
|
||||
databaseDisabled={false}
|
||||
databaseOnChange={onDropdownChange("sourceDatabase")}
|
||||
containerOptions={sourceContainerOptions}
|
||||
selectedContainer={source?.containerId}
|
||||
containerDisabled={!source?.databaseId}
|
||||
containerOnChange={onDropdownChange("sourceContainer")}
|
||||
/>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.targetContainerSubHeading}
|
||||
databaseOptions={targetDatabaseOptions}
|
||||
selectedDatabase={target?.databaseId}
|
||||
databaseDisabled={false}
|
||||
databaseOnChange={onDropdownChange("targetDatabase")}
|
||||
containerOptions={targetContainerOptions}
|
||||
selectedContainer={target?.containerId}
|
||||
containerDisabled={!target?.databaseId}
|
||||
containerOnChange={onDropdownChange("targetContainer")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectSourceAndTargetContainers;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types";
|
||||
|
||||
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
|
||||
const { source, target } = copyJobState ?? {};
|
||||
const selectedSourceAccount = source?.account;
|
||||
const selectedTargetAccount = target?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
const {
|
||||
subscriptionId: targetSubscriptionId,
|
||||
resourceGroup: targetResourceGroup,
|
||||
accountName: targetAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
||||
|
||||
const sourceDbParams = React.useMemo(
|
||||
() => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams,
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName],
|
||||
);
|
||||
|
||||
const sourceContainerParams = React.useMemo(
|
||||
() =>
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams,
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId],
|
||||
);
|
||||
|
||||
const targetDbParams = React.useMemo(
|
||||
() => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams,
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName],
|
||||
);
|
||||
|
||||
const targetContainerParams = React.useMemo(
|
||||
() =>
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams,
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId],
|
||||
);
|
||||
|
||||
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import { useSidePanel } from "../../../../hooks/useSidePanel";
|
||||
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
|
||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
||||
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 { 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(() => {
|
||||
const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState;
|
||||
return !currentScreen?.validations.every((v) => v.validate(context));
|
||||
}, [currentScreen.key, copyJobState, cache]);
|
||||
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 handlePrimary = useCallback(() => {
|
||||
const transitions = {
|
||||
[SCREEN_KEYS.SelectAccount]: 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) {
|
||||
submitCreateCopyJob(copyJobState, handleCancel);
|
||||
}
|
||||
}, [currentScreenKey, copyJobState]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
dispatch({ type: "PREVIOUS" });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentScreen,
|
||||
isPrimaryDisabled,
|
||||
isPreviousDisabled,
|
||||
handlePrimary,
|
||||
handlePrevious,
|
||||
handleCancel,
|
||||
primaryBtnText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface CopyJobPrerequisitesCacheState {
|
||||
validationCache: Map<string, boolean>;
|
||||
setValidationCache: (cache: Map<string, boolean>) => void;
|
||||
}
|
||||
|
||||
export const useCopyJobPrerequisitesCache = create<CopyJobPrerequisitesCacheState>((set) => ({
|
||||
validationCache: new Map<string, boolean>(),
|
||||
setValidationCache: (cache) => set({ validationCache: cache }),
|
||||
}));
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { CopyJobContextState } from "../../Types";
|
||||
import AssignPermissions from "../Screens/AssignPermissions";
|
||||
import PreviewCopyJob from "../Screens/PreviewCopyJob";
|
||||
import SelectAccount from "../Screens/SelectAccount";
|
||||
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers";
|
||||
|
||||
const SCREEN_KEYS = {
|
||||
SelectAccount: "SelectAccount",
|
||||
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
|
||||
PreviewCopyJob: "PreviewCopyJob",
|
||||
AssignPermissions: "AssignPermissions",
|
||||
};
|
||||
|
||||
type Validation = {
|
||||
validate: (state: CopyJobContextState | Map<string, boolean>) => boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Screen = {
|
||||
key: string;
|
||||
component: React.ReactElement;
|
||||
validations: Validation[];
|
||||
};
|
||||
|
||||
function useCreateCopyJobScreensList() {
|
||||
return React.useMemo<Screen[]>(
|
||||
() => [
|
||||
{
|
||||
key: SCREEN_KEYS.SelectAccount,
|
||||
component: <SelectAccount />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account,
|
||||
message: "Please select a subscription and account to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.SelectSourceAndTargetContainers,
|
||||
component: <SelectSourceAndTargetContainers />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) =>
|
||||
!!state?.source?.databaseId &&
|
||||
!!state?.source?.containerId &&
|
||||
!!state?.target?.databaseId &&
|
||||
!!state?.target?.containerId,
|
||||
message: "Please select source and target containers to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.PreviewCopyJob,
|
||||
component: <PreviewCopyJob />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) =>
|
||||
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)),
|
||||
message: "Please enter a job name to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.AssignPermissions,
|
||||
component: <AssignPermissions />,
|
||||
validations: [
|
||||
{
|
||||
validate: (cache: Map<string, boolean>) => {
|
||||
const cacheValuesIterator = Array.from(cache.values());
|
||||
if (cacheValuesIterator.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allValid = cacheValuesIterator.every((isValid: boolean) => isValid);
|
||||
return allValid;
|
||||
},
|
||||
message: "Please ensure all previous steps are valid to proceed",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export { SCREEN_KEYS, useCreateCopyJobScreensList };
|
||||
40
src/Explorer/ContainerCopy/Enums/index.ts
Normal file
40
src/Explorer/ContainerCopy/Enums/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export enum CopyJobMigrationType {
|
||||
Offline = "offline",
|
||||
Online = "online",
|
||||
}
|
||||
|
||||
// all checks will happen
|
||||
export enum IdentityType {
|
||||
SystemAssigned = "systemassigned", // "SystemAssigned"
|
||||
UserAssigned = "userassigned", // "UserAssigned"
|
||||
None = "none", // "None"
|
||||
}
|
||||
|
||||
export enum DefaultIdentityType {
|
||||
SystemAssignedIdentity = "systemassignedidentity", // "SystemAssignedIdentity"
|
||||
}
|
||||
|
||||
export enum BackupPolicyType {
|
||||
Continuous = "Continuous",
|
||||
Periodic = "Periodic",
|
||||
}
|
||||
|
||||
export enum CopyJobStatusType {
|
||||
Pending = "Pending",
|
||||
InProgress = "InProgress",
|
||||
Running = "Running",
|
||||
Partitioning = "Partitioning",
|
||||
Paused = "Paused",
|
||||
Skipped = "Skipped",
|
||||
Completed = "Completed",
|
||||
Cancelled = "Cancelled",
|
||||
Failed = "Failed",
|
||||
Faulted = "Faulted",
|
||||
}
|
||||
|
||||
export enum CopyJobActions {
|
||||
pause = "pause",
|
||||
resume = "resume",
|
||||
cancel = "cancel",
|
||||
complete = "complete",
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums";
|
||||
import { CopyJobType } from "../../Types";
|
||||
|
||||
interface CopyJobActionMenuProps {
|
||||
job: CopyJobType;
|
||||
handleClick: (job: CopyJobType, action: string) => void;
|
||||
}
|
||||
|
||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
||||
if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getMenuItems = (): IContextualMenuProps["items"] => {
|
||||
const baseItems = [
|
||||
{
|
||||
key: CopyJobActions.pause,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
||||
iconProps: { iconName: "Pause" },
|
||||
onClick: () => handleClick(job, CopyJobActions.pause),
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.cancel,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
||||
iconProps: { iconName: "Cancel" },
|
||||
onClick: () => handleClick(job, CopyJobActions.cancel),
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.resume,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
||||
iconProps: { iconName: "Play" },
|
||||
onClick: () => handleClick(job, CopyJobActions.resume),
|
||||
},
|
||||
];
|
||||
|
||||
if (CopyJobStatusType.Paused === job.Status) {
|
||||
return baseItems.filter((item) => item.key !== CopyJobActions.pause);
|
||||
}
|
||||
|
||||
if (CopyJobStatusType.Pending === job.Status) {
|
||||
return baseItems.filter((item) => item.key !== CopyJobActions.resume);
|
||||
}
|
||||
|
||||
if (
|
||||
[CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status)
|
||||
) {
|
||||
const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume);
|
||||
if (job.Mode === CopyJobMigrationType.Online) {
|
||||
filteredItems.push({
|
||||
key: CopyJobActions.complete,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
||||
iconProps: { iconName: "CheckMark" },
|
||||
onClick: () => handleClick(job, CopyJobActions.complete),
|
||||
});
|
||||
}
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) {
|
||||
return baseItems.filter((item) => item.key === CopyJobActions.resume);
|
||||
}
|
||||
|
||||
return baseItems;
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
role="button"
|
||||
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
menuIconProps={{ iconName: "" }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobActionMenu;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { IColumn } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobType } from "../../Types";
|
||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
||||
|
||||
export const getColumns = (
|
||||
handleSort: (columnKey: string) => void,
|
||||
handleActionClick: (job: CopyJobType, action: string) => void,
|
||||
sortedColumnKey: string | undefined,
|
||||
isSortedDescending: boolean,
|
||||
): IColumn[] => [
|
||||
{
|
||||
key: "LastUpdatedTime",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
|
||||
fieldName: "LastUpdatedTime",
|
||||
minWidth: 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"),
|
||||
},
|
||||
{
|
||||
key: "Mode",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
|
||||
fieldName: "Mode",
|
||||
minWidth: 90,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Mode",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onColumnClick: () => handleSort("Mode"),
|
||||
},
|
||||
{
|
||||
key: "CompletionPercentage",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
|
||||
fieldName: "CompletionPercentage",
|
||||
minWidth: 110,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "CompletionPercentage",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`,
|
||||
onColumnClick: () => handleSort("CompletionPercentage"),
|
||||
},
|
||||
{
|
||||
key: "CopyJobStatus",
|
||||
name: ContainerCopyMessages.MonitorJobs.Columns.status,
|
||||
fieldName: "Status",
|
||||
minWidth: 130,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
isSorted: sortedColumnKey === "Status",
|
||||
isSortedDescending: isSortedDescending,
|
||||
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
|
||||
onColumnClick: () => handleSort("Status"),
|
||||
},
|
||||
{
|
||||
key: "Actions",
|
||||
name: "",
|
||||
minWidth: 80,
|
||||
maxWidth: 200,
|
||||
isResizable: true,
|
||||
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums";
|
||||
|
||||
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: Record<CopyJobStatusType, string> = {
|
||||
[CopyJobStatusType.Pending]: "StatusCircleRing",
|
||||
[CopyJobStatusType.InProgress]: "ProgressRingDots",
|
||||
[CopyJobStatusType.Running]: "ProgressRingDots",
|
||||
[CopyJobStatusType.Partitioning]: "ProgressRingDots",
|
||||
[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";
|
||||
return (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<FontIcon
|
||||
aria-label={status}
|
||||
iconName={iconMap[status] || "UnknownSolid"}
|
||||
className={classNames[status] || classNames.unknown}
|
||||
/>
|
||||
<Text>{statusText}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobStatusWithIcon;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ActionButton, Image } from "@fluentui/react";
|
||||
import React, { useCallback } from "react";
|
||||
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
|
||||
import * as Actions from "../../Actions/CopyJobActions";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
|
||||
interface CopyJobsNotFoundProps {}
|
||||
|
||||
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = () => {
|
||||
const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []);
|
||||
return (
|
||||
<div className="notFoundContainer flexContainer centerContent">
|
||||
<Image src={CopyJobIcon} alt={ContainerCopyMessages.noCopyJobsTitle} width={100} height={100} />
|
||||
<h4 className="noCopyJobsMessage">{ContainerCopyMessages.noCopyJobsTitle}</h4>
|
||||
<ActionButton allowDisabledFocus className="createCopyJobButton" onClick={handleCreateCopyJob}>
|
||||
{ContainerCopyMessages.createCopyJobButtonText}
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobsNotFound;
|
||||
@@ -0,0 +1,103 @@
|
||||
/* 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 { CopyJobType } from "../../Types";
|
||||
import { getColumns } from "./CopyJobColumns";
|
||||
|
||||
interface CopyJobsListProps {
|
||||
jobs: CopyJobType[];
|
||||
handleActionClick: (job: CopyJobType, action: string) => void;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { height: "calc(100vh - 15em)" } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 100; // Number of items per page
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const [startIndex] = React.useState(0);
|
||||
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
|
||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
||||
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSortedJobs(jobs);
|
||||
}, [jobs]);
|
||||
|
||||
const handleSort = (columnKey: string) => {
|
||||
const isDescending = sortedColumnKey === columnKey ? !isSortedDescending : false;
|
||||
const sorted = [...sortedJobs].sort((current: any, next: any) => {
|
||||
if (current[columnKey] < next[columnKey]) {
|
||||
return isDescending ? 1 : -1;
|
||||
}
|
||||
if (current[columnKey] > next[columnKey]) {
|
||||
return isDescending ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setSortedJobs(sorted);
|
||||
setSortedColumnKey(columnKey);
|
||||
setIsSortedDescending(isDescending);
|
||||
};
|
||||
|
||||
const columns: IColumn[] = React.useMemo(
|
||||
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
|
||||
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
|
||||
);
|
||||
|
||||
const _handleRowClick = React.useCallback((job: CopyJobType) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Row clicked:", job);
|
||||
}, []);
|
||||
|
||||
const _onRenderRow = React.useCallback((props: any) => {
|
||||
return (
|
||||
<div onClick={_handleRowClick.bind(null, props.item)}>
|
||||
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// const totalCount = jobs.length;
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Stack verticalFill={true}>
|
||||
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
|
||||
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
|
||||
<ShimmeredDetailsList
|
||||
onRenderRow={_onRenderRow}
|
||||
checkboxVisibility={2}
|
||||
columns={columns}
|
||||
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
|
||||
enableShimmer={false}
|
||||
constrainMode={ConstrainMode.unconstrained}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
onRenderDetailsHeader={(props, defaultRender) => (
|
||||
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
|
||||
{defaultRender({ ...props })}
|
||||
</Sticky>
|
||||
)}
|
||||
/>
|
||||
</ScrollablePane>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobsList;
|
||||
@@ -0,0 +1,12 @@
|
||||
import create from "zustand";
|
||||
import { MonitorCopyJobsRef } from "./MonitorCopyJobs";
|
||||
|
||||
type MonitorCopyJobsRefStateType = {
|
||||
ref: MonitorCopyJobsRef;
|
||||
setRef: (ref: MonitorCopyJobsRef) => void;
|
||||
};
|
||||
|
||||
export const MonitorCopyJobsRefState = create<MonitorCopyJobsRefStateType>((set) => ({
|
||||
ref: null,
|
||||
setRef: (ref) => set({ ref: ref }),
|
||||
}));
|
||||
118
src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
Normal file
118
src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree";
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
|
||||
import { convertToCamelCase } from "../CopyJobUtils";
|
||||
import { CopyJobStatusType } from "../Enums";
|
||||
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
|
||||
import { CopyJobType } from "../Types";
|
||||
import CopyJobsList from "./Components/CopyJobsList";
|
||||
|
||||
const FETCH_INTERVAL_MS = 30 * 1000; // Interval time in milliseconds (30 seconds)
|
||||
|
||||
interface MonitorCopyJobsProps {}
|
||||
|
||||
export interface MonitorCopyJobsRef {
|
||||
refreshJobList: () => void;
|
||||
}
|
||||
|
||||
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
|
||||
const [loading, setLoading] = React.useState(true); // Start with loading as true
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
|
||||
const isUpdatingRef = React.useRef(false); // Use ref to track updating state
|
||||
const isFirstFetchRef = React.useRef(true); // Use ref to track the first fetch
|
||||
|
||||
const indentLevels = React.useMemo<IndentLevel[]>(() => Array(7).fill({ level: 0, width: "100%" }), []);
|
||||
|
||||
const fetchJobs = React.useCallback(async () => {
|
||||
if (isUpdatingRef.current) {
|
||||
return;
|
||||
} // Skip if an update is in progress
|
||||
try {
|
||||
if (isFirstFetchRef.current) {
|
||||
setLoading(true);
|
||||
} // Show loading spinner only for the first fetch
|
||||
setError(null);
|
||||
|
||||
const response = await getCopyJobs();
|
||||
setJobs((prevJobs) => {
|
||||
// Only update jobs if they are different
|
||||
const isSame = JSON.stringify(prevJobs) === JSON.stringify(response);
|
||||
return isSame ? prevJobs : response;
|
||||
});
|
||||
} catch (error) {
|
||||
setError(error.message || "Failed to load copy jobs. Please try again later.");
|
||||
} finally {
|
||||
if (isFirstFetchRef.current) {
|
||||
setLoading(false); // Hide loading spinner after the first fetch
|
||||
isFirstFetchRef.current = false; // Mark the first fetch as complete
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchJobs]);
|
||||
|
||||
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) => {
|
||||
try {
|
||||
isUpdatingRef.current = true; // Mark as updating
|
||||
const updatedCopyJob = await updateCopyJobStatus(job, action);
|
||||
if (updatedCopyJob) {
|
||||
setJobs((prevJobs) =>
|
||||
prevJobs.map((prevJob) =>
|
||||
prevJob.Name === updatedCopyJob.properties.jobName
|
||||
? {
|
||||
...prevJob,
|
||||
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; // Mark as not updating
|
||||
}
|
||||
}, []);
|
||||
|
||||
const memoizedJobsList = React.useMemo(() => {
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (jobs.length > 0) {
|
||||
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
|
||||
}
|
||||
return <CopyJobsNotFound />;
|
||||
}, [jobs, loading, handleActionClick]);
|
||||
|
||||
return (
|
||||
<Stack className="monitorCopyJobs flexContainer">
|
||||
{loading && <ShimmerTree indentLevels={indentLevels} style={{ width: "100%", padding: "1rem 2.5rem" }} />}
|
||||
{error && (
|
||||
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</MessageBar>
|
||||
)}
|
||||
{memoizedJobsList}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default MonitorCopyJobs;
|
||||
132
src/Explorer/ContainerCopy/Types/index.ts
Normal file
132
src/Explorer/ContainerCopy/Types/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { DatabaseAccount, Subscription } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { ApiType } from "UserContext";
|
||||
import Explorer from "../../Explorer";
|
||||
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums";
|
||||
|
||||
export interface ContainerCopyProps {
|
||||
container: Explorer;
|
||||
}
|
||||
|
||||
export type CopyJobCommandBarBtnType = {
|
||||
key: string;
|
||||
iconSrc: string;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export type CopyJobTabForwardRefHandle = {
|
||||
validate: (state: CopyJobContextState) => boolean;
|
||||
};
|
||||
|
||||
export type DropdownOptionType = {
|
||||
key: string;
|
||||
text: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type DatabaseParams = [string | undefined, string | undefined, string | undefined, ApiType];
|
||||
export type DataContainerParams = [
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
string | undefined,
|
||||
ApiType,
|
||||
];
|
||||
|
||||
export interface DatabaseContainerSectionProps {
|
||||
heading: string;
|
||||
databaseOptions: DropdownOptionType[];
|
||||
selectedDatabase: string;
|
||||
databaseDisabled?: boolean;
|
||||
databaseOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
||||
containerOptions: DropdownOptionType[];
|
||||
selectedContainer: string;
|
||||
containerDisabled?: boolean;
|
||||
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
||||
}
|
||||
|
||||
export interface CopyJobContextState {
|
||||
jobName: string;
|
||||
migrationType: CopyJobMigrationType;
|
||||
sourceReadAccessFromTarget?: boolean;
|
||||
// source details
|
||||
source: {
|
||||
subscription: Subscription;
|
||||
account: DatabaseAccount;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
};
|
||||
// target details
|
||||
target: {
|
||||
subscriptionId: string;
|
||||
account: DatabaseAccount;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CopyJobFlowType {
|
||||
currentScreen: string;
|
||||
}
|
||||
|
||||
export interface CopyJobContextProviderType {
|
||||
flow: CopyJobFlowType;
|
||||
setFlow: React.Dispatch<React.SetStateAction<CopyJobFlowType>>;
|
||||
copyJobState: CopyJobContextState | null;
|
||||
setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>;
|
||||
resetCopyJobState: () => void;
|
||||
}
|
||||
|
||||
export type CopyJobType = {
|
||||
ID: string;
|
||||
Mode: string;
|
||||
Name: string;
|
||||
Status: CopyJobStatusType;
|
||||
CompletionPercentage: number;
|
||||
Duration: string;
|
||||
LastUpdatedTime: string;
|
||||
timestamp: number;
|
||||
Error?: CopyJobErrorType;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
128
src/Explorer/ContainerCopy/containerCopyStyles.less
Normal file
128
src/Explorer/ContainerCopy/containerCopyStyles.less
Normal file
@@ -0,0 +1,128 @@
|
||||
@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;
|
||||
.bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button[role="button"] {
|
||||
&.ms-Button--icon {
|
||||
i.ms-Icon {
|
||||
font-size: @LargeSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/Explorer/ContainerCopy/index.tsx
Normal file
23
src/Explorer/ContainerCopy/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { MonitorCopyJobsRefState } from "Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import React, { useEffect } from "react";
|
||||
import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar";
|
||||
import "./containerCopyStyles.less";
|
||||
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
|
||||
import { ContainerCopyProps } from "./Types";
|
||||
|
||||
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
|
||||
useEffect(() => {
|
||||
if (monitorCopyJobsRef.current) {
|
||||
MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current);
|
||||
}
|
||||
}, [monitorCopyJobsRef.current]);
|
||||
return (
|
||||
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
|
||||
<CopyJobCommandBar container={container} />
|
||||
<MonitorCopyJobs ref={monitorCopyJobsRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerCopyPanel;
|
||||
@@ -8,6 +8,7 @@ export interface PanelContainerProps {
|
||||
panelContent?: JSX.Element;
|
||||
isConsoleExpanded: boolean;
|
||||
isOpen: boolean;
|
||||
hasConsole?: boolean;
|
||||
isConsoleAnimationFinished?: boolean;
|
||||
panelWidth?: string;
|
||||
onRenderNavigationContent?: IRenderFunction<IPanelProps>;
|
||||
@@ -86,6 +87,9 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
|
||||
};
|
||||
|
||||
private getPanelHeight = (): string => {
|
||||
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<PanelContainerProps
|
||||
export const SidePanel: React.FC = () => {
|
||||
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 (
|
||||
<PanelContainerComponent
|
||||
hasConsole={hasConsole}
|
||||
isOpen={isOpen}
|
||||
panelContent={panelContent}
|
||||
headerText={headerText}
|
||||
|
||||
49
src/Main.tsx
49
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";
|
||||
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";
|
||||
@@ -75,30 +78,23 @@ const App: React.FunctionComponent = () => {
|
||||
}
|
||||
StyleConstants.updateStyles();
|
||||
const explorer = useKnockoutExplorer(config?.platform);
|
||||
// console.log("Using config: ", config);
|
||||
|
||||
if (!explorer) {
|
||||
return <LoadingExplorer />;
|
||||
}
|
||||
// console.log("Using explorer: ", explorer);
|
||||
// console.log("Using userContext: ", userContext);
|
||||
|
||||
return (
|
||||
<KeyboardShortcutRoot>
|
||||
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
|
||||
<div id="divExplorer" className="flexContainer hideOverflows">
|
||||
<div id="freeTierTeachingBubble"> </div>
|
||||
{/* Main Command Bar - Start */}
|
||||
<CommandBar container={explorer} />
|
||||
{/* Collections Tree and Tabs - Begin */}
|
||||
<SidebarContainer explorer={explorer} />
|
||||
{/* Collections Tree and Tabs - End */}
|
||||
<div
|
||||
className="dataExplorerErrorConsoleContainer"
|
||||
role="contentinfo"
|
||||
aria-label="Notification console"
|
||||
id="explorerNotificationConsole"
|
||||
>
|
||||
<NotificationConsole />
|
||||
</div>
|
||||
</div>
|
||||
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? (
|
||||
<ContainerCopyPanel container={explorer} />
|
||||
) : (
|
||||
<DivExplorer explorer={explorer} />
|
||||
)}
|
||||
|
||||
<SidePanel />
|
||||
<Dialog />
|
||||
{<QuickstartCarousel isOpen={isCarouselOpen} />}
|
||||
@@ -113,6 +109,27 @@ const App: React.FunctionComponent = () => {
|
||||
const mainElement = document.getElementById("Main");
|
||||
ReactDOM.render(<App />, mainElement);
|
||||
|
||||
function DivExplorer({ explorer }: { explorer: Explorer }): JSX.Element {
|
||||
return (
|
||||
<div id="divExplorer" className="flexContainer hideOverflows">
|
||||
<div id="freeTierTeachingBubble"> </div>
|
||||
{/* Main Command Bar - Start */}
|
||||
<CommandBar container={explorer} />
|
||||
{/* Collections Tree and Tabs - Begin */}
|
||||
<SidebarContainer explorer={explorer} />
|
||||
{/* Collections Tree and Tabs - End */}
|
||||
<div
|
||||
className="dataExplorerErrorConsoleContainer"
|
||||
role="contentinfo"
|
||||
aria-label="Notification console"
|
||||
id="explorerNotificationConsole"
|
||||
>
|
||||
<NotificationConsole />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingExplorer(): JSX.Element {
|
||||
return (
|
||||
<div className="splashLoaderContainer">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ describe("AuthorizationUtils", () => {
|
||||
const setAadDataPlane = (enabled: boolean) => {
|
||||
updateUserContext({
|
||||
features: {
|
||||
enableContainerCopy: false,
|
||||
enableAadDataPlane: enabled,
|
||||
canExceedMaximumValue: false,
|
||||
cosmosdb: false,
|
||||
|
||||
12
src/Utils/CopyJobAuthUtils.ts
Normal file
12
src/Utils/CopyJobAuthUtils.ts
Normal file
@@ -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;
|
||||
}
|
||||
120
src/Utils/arm/RbacUtils.ts
Normal file
120
src/Utils/arm/RbacUtils.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { configContext } from "ConfigContext";
|
||||
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 getArmBaseUrl = (): string => {
|
||||
const base = configContext.ARM_ENDPOINT;
|
||||
return base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
};
|
||||
|
||||
const buildArmUrl = (path: string): string => `${getArmBaseUrl()}${path}?api-version=${apiVersion}`;
|
||||
|
||||
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<RoleAssignmentType[]> => {
|
||||
const uri = buildArmUrl(
|
||||
`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments`,
|
||||
);
|
||||
|
||||
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<RoleDefinitionType[]> => {
|
||||
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));
|
||||
|
||||
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<RoleAssignmentType | null> => {
|
||||
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;
|
||||
};
|
||||
37
src/Utils/arm/databaseAccountUtils.ts
Normal file
37
src/Utils/arm/databaseAccountUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { userContext } from "UserContext";
|
||||
import { configContext } from "../../ConfigContext";
|
||||
|
||||
const apiVersion = "2025-04-15";
|
||||
export type FetchAccountDetailsParams = {
|
||||
subscriptionId: string;
|
||||
resourceGroupName: string;
|
||||
accountName: string;
|
||||
};
|
||||
|
||||
const buildUrl = (params: FetchAccountDetailsParams): string => {
|
||||
const { subscriptionId, resourceGroupName, accountName } = params;
|
||||
|
||||
let armEndpoint = configContext.ARM_ENDPOINT;
|
||||
if (armEndpoint.endsWith("/")) {
|
||||
armEndpoint = armEndpoint.slice(0, -1);
|
||||
}
|
||||
return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}?api-version=${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;
|
||||
}
|
||||
@@ -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<Types.DataTransferJobGetResults> {
|
||||
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<Types.DataTransferJobFeedResults> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
57
src/Utils/arm/identityUtils.ts
Normal file
57
src/Utils/arm/identityUtils.ts
Normal file
@@ -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<DatabaseAccount | null> => {
|
||||
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<DatabaseAccount | null> => {
|
||||
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<DatabaseAccount | null> => {
|
||||
const body = {
|
||||
properties: {
|
||||
defaultIdentity: "SystemAssignedIdentity",
|
||||
},
|
||||
};
|
||||
const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body);
|
||||
return updatedAccount;
|
||||
};
|
||||
|
||||
export { updateDefaultIdentity, updateSystemIdentity };
|
||||
@@ -48,6 +48,7 @@ interface Options {
|
||||
queryParams?: ARMQueryParams;
|
||||
contentType?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function armRequestWithoutPolling<T>({
|
||||
@@ -59,6 +60,7 @@ export async function armRequestWithoutPolling<T>({
|
||||
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<T>({
|
||||
method,
|
||||
headers,
|
||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -116,6 +119,7 @@ export async function armRequest<T>({
|
||||
queryParams,
|
||||
contentType,
|
||||
customHeaders,
|
||||
signal,
|
||||
}: Options): Promise<T> {
|
||||
const armRequestResult = await armRequestWithoutPolling<T>({
|
||||
host,
|
||||
@@ -126,6 +130,7 @@ export async function armRequest<T>({
|
||||
queryParams,
|
||||
contentType,
|
||||
customHeaders,
|
||||
signal,
|
||||
});
|
||||
const operationStatusUrl = armRequestResult.operationStatusUrl;
|
||||
if (operationStatusUrl) {
|
||||
|
||||
75
src/hooks/useDataContainers.tsx
Normal file
75
src/hooks/useDataContainers.tsx
Normal file
@@ -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<DatabaseModel[]> => {
|
||||
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;
|
||||
}
|
||||
@@ -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<DatabaseAccount[]> {
|
||||
export async function fetchDatabaseAccounts(
|
||||
subscriptionId: string,
|
||||
accessToken: string = "",
|
||||
): Promise<DatabaseAccount[]> {
|
||||
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<DatabaseAccount[]> {
|
||||
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;
|
||||
|
||||
65
src/hooks/useDatabases.tsx
Normal file
65
src/hooks/useDatabases.tsx
Normal file
@@ -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<DatabaseModel[]> => {
|
||||
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;
|
||||
}
|
||||
@@ -960,6 +960,14 @@ 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;
|
||||
|
||||
@@ -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<HTMLElement>; // Optional ref for focusing the last element.
|
||||
}
|
||||
export const useSidePanel: UseStore<SidePanelState> = 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: () => {
|
||||
|
||||
@@ -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<Subscription[]> {
|
||||
export async function fetchSubscriptions(accessToken: string = ""): Promise<Subscription[]> {
|
||||
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<Subscript
|
||||
return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionsFromGraph(accessToken: string): Promise<Subscription[]> {
|
||||
export async function fetchSubscriptionsFromGraph(accessToken: string = ""): Promise<Subscription[]> {
|
||||
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;
|
||||
|
||||
@@ -21,9 +21,9 @@ const version = "2025-05-01-preview";
|
||||
"cosmos" | "managedCassandra" | "mongorbac" | "notebook" | "privateEndpointConnection" | "privateLinkResources" |
|
||||
"rbac" | "restorable" | "services" | "dataTransferService"
|
||||
*/
|
||||
const githubResourceName = "cosmos-db";
|
||||
const deResourceName = "cosmos";
|
||||
const schemaURL = `https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/${version}/${githubResourceName}.json`;
|
||||
const githubResourceName = "dataTransferService";
|
||||
const deResourceName = "dataTransferService";
|
||||
const schemaURL = `https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/${version}/${githubResourceName}.json`;
|
||||
const outputDir = path.join(__dirname, `../../src/Utils/arm/generatedClients/${deResourceName}`);
|
||||
|
||||
// Array of strings to use for eventual output
|
||||
|
||||
Reference in New Issue
Block a user