Compare commits

..

12 Commits

Author SHA1 Message Date
Dmitrii Shilov
f4598fdea4 Merge branch 'master' into users/dshilov/restore-container 2025-12-03 15:31:42 +01:00
BChoudhury-ms
9a6f090374 Refactor Container Copy Permissions Screen: Group-based Validation and Improved Loading UX (#2269)
* grouped permissions and added styles

* Adding loading overlay for the permission sections
2025-12-03 07:43:13 +05:30
BChoudhury-ms
63cddeb4b8 Integrate container creation screen to copy job flow (#2265) 2025-11-27 13:19:50 +05:30
BChoudhury-ms
bb0bbd8a6e show default copy job name (#2266) 2025-11-27 10:34:08 +05:30
asier-isayas
a33429fd85 Add Session Id (#2263)
* adding sessionId to UserContext

* add session id

* add session id to settings pane and fix npm run compile

* Add conditional for Portal

* set default session id on userContext init

* fix tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-11-26 10:07:18 -08:00
BChoudhury-ms
784dadce30 set intra-account copy as the default one (#2267) 2025-11-26 10:06:45 -08:00
Dmitrii Shilov
7ceb3775c8 Merge branch 'master' into users/dshilov/restore-container
# Conflicts:
#	src/Platform/Hosted/extractFeatures.ts
2025-11-14 13:28:50 +01:00
Dmitrii Shilov
e1318bd90f Merge branch 'master' into users/dshilov/restore-container 2025-10-21 10:36:43 +02:00
Dmitrii Shilov
958ca2b3a5 Merge branch 'master' into users/dshilov/restore-container
# Conflicts:
#	src/Contracts/DataExplorerMessagesContract.ts
#	src/Contracts/FabricMessageTypes.ts
2025-10-14 14:02:05 +02:00
Dmitrii Shilov
4ee4ff0a14 feat: Enhance restore container functionality with error handling and UI updates 2025-09-23 18:36:27 +02:00
Dmitrii Shilov
5302da2dff feat: Enhance restore container functionality with error handling and UI updates 2025-09-23 18:26:52 +02:00
Dmitrii Shilov
182cb2ef22 feat: Enhance restore container functionality with error handling and UI updates 2025-09-23 18:19:39 +02:00
35 changed files with 315 additions and 173 deletions

88
package-lock.json generated
View File

@@ -116,6 +116,7 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -626,6 +627,14 @@
}
}
},
"node_modules/@azure/ms-rest-js/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@azure/ms-rest-js/node_modules/xml2js": {
"version": "0.5.0",
"license": "MIT",
@@ -685,6 +694,14 @@
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
@@ -7595,6 +7612,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/commutable/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/connected-components": {
"version": "6.8.2",
"license": "BSD-3-Clause",
@@ -9125,6 +9150,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/fixtures/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/iron-icons": {
"version": "1.0.0",
"license": "BSD-3-Clause",
@@ -9282,6 +9315,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/messaging/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/monaco-editor": {
"version": "3.2.2",
"license": "BSD-3-Clause",
@@ -9397,6 +9438,14 @@
"version": "0.18.1",
"license": "MIT"
},
"node_modules/@nteract/monaco-editor/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/mythic-configuration": {
"version": "1.0.12",
"license": "BSD-3-Clause",
@@ -9665,6 +9714,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/reducers/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/selectors": {
"version": "3.2.0",
"license": "BSD-3-Clause",
@@ -9888,6 +9945,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/types/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@octokit/auth-token": {
"version": "4.0.0",
"license": "MIT",
@@ -26419,6 +26484,15 @@
"xmlbuilder": "^15.1.0"
}
},
"node_modules/jest-trx-results-processor/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/jest-util": {
"version": "24.9.0",
"license": "MIT",
@@ -33753,6 +33827,15 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"license": "BSD-3-Clause",
@@ -35619,8 +35702,9 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"license": "MIT",
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"bin": {
"uuid": "dist/bin/uuid"
}

View File

@@ -46,8 +46,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/xterm": "5.5.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -111,6 +111,7 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -248,4 +249,4 @@
"printWidth": 120,
"endOfLine": "auto"
}
}
}

View File

@@ -297,6 +297,7 @@ export class HttpHeaders {
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
public static xAPIKey: string = "X-API-Key";
public static sessionId: string = "x-ms-client-session-id";
}
export class ContentType {

View File

@@ -44,8 +44,8 @@ export const getDatabaseEndpoint = (apiType: ApiType): string => {
return "gremlinDatabases";
case "Tables":
return "tables";
case "SQL":
default:
case "SQL":
return "sqlDatabases";
}
};
@@ -58,8 +58,8 @@ export const getCollectionEndpoint = (apiType: ApiType): string => {
return "tables";
case "Gremlin":
return "graphs";
case "SQL":
default:
case "SQL":
return "containers";
}
};

View File

@@ -17,6 +17,7 @@ const defaultHeaders = {
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
[CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100",
[CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15",
[HttpHeaders.sessionId]: userContext.sessionId,
};
function authHeaders() {

View File

@@ -46,6 +46,10 @@ export type DataExploreMessageV3 =
params: {
updateType: "created" | "deleted" | "settings";
};
}
| {
type: FabricMessageTypes.RestoreContainer;
params: [];
};
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";

View File

@@ -73,7 +73,6 @@ export interface DatabaseAccountExtendedProperties {
publicNetworkAccess?: string;
enablePriorityBasedExecution?: boolean;
vcoreMongoEndpoint?: string;
enableAllVersionsAndDeletesChangeFeed?: boolean;
}
export interface DatabaseAccountResponseLocation {

View File

@@ -446,6 +446,7 @@ export interface DataExplorerInputsFrame {
feedbackPolicies?: any;
aadToken?: string;
containerCopyEnabled?: boolean;
sessionId?: string;
}
export interface SelfServeFrameInputs {

View File

@@ -23,7 +23,6 @@ import {
extractErrorMessage,
formatUTCDateTime,
getAccountDetailsFromResourceId,
isIntraAccountCopy,
} from "../CopyJobUtils";
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
@@ -76,6 +75,7 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
}
copyJobsAbortController = null;
/* added a lower bound to "0" and upper bound to "100" */
const calculateCompletionPercentage = (processed: number, total: number): number => {
if (
typeof processed !== "number" ||
@@ -139,12 +139,11 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const isSameAccount = isIntraAccountCopy(source?.account?.id, target?.account?.id);
const body = {
properties: {
source: {
component: "CosmosDBSql",
...(isSameAccount ? {} : { accountName: source?.account?.name }),
remoteAccountName: source?.account?.name,
databaseName: source?.databaseId,
containerName: source?.containerId,
},

View File

@@ -55,15 +55,13 @@ export default {
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`,
crossAccountConfiguration: {
title: "Cross-account container copy",
description: (sourceAccount: string, destinationAccount: string) =>
`Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
commonConfiguration: {
title: "Common configuration",
description: "Basic permissions required for copy operations",
},
onlineConfiguration: {
title: "Online container copy",
description: (accountName: string) =>
`Please follow the instructions below to enable online copy on your "${accountName}" account.`,
title: "Online copy configuration",
description: "Additional permissions required for online copy operations",
},
},
toggleBtn: {
@@ -131,17 +129,10 @@ export default {
},
onlineCopyEnabled: {
title: "Online copy enabled",
description: (accountName: string) =>
`Enable online container copy by clicking the button below on your "${accountName}" account.`,
description: (accountName: string) => `Enable Online copy on "${accountName}".`,
hrefText: "Learn more about online copy jobs",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
buttonText: "Enable Online Copy",
validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Validating All versions and deletes change feed mode (preview)...",
enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Enabling All versions and deletes change feed mode (preview)...",
enablingOnlineCopySpinnerLabel: (accountName: string) =>
`Enabling online copy on your "${accountName}" account ...`,
},
MonitorJobs: {
Columns: {

View File

@@ -1,5 +1,5 @@
import { Subscription } from "Contracts/DataModels";
import Explorer from "Explorer/Explorer";
import { Subscription } from "Contracts/DataModels";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";

View File

@@ -132,6 +132,7 @@ export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAc
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
);
}
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
if (prevJobs.length !== newJobs.length) {
return false;

View File

@@ -1,5 +1,5 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import React, { useCallback } from "react";
import { logError } from "../../../../../Common/Logger";
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
@@ -25,7 +25,7 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const handleAddReadPermission = async () => {
const handleAddReadPermission = useCallback(async () => {
const { source, target } = copyJobState;
const selectedSourceAccount = source?.account;
try {
@@ -53,9 +53,10 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
setContextError(errorMessage);
} finally {
setLoading(false);
}
};
}, [copyJobState, setCopyJobState, setContextError]);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>

View File

@@ -31,7 +31,7 @@ const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Compo
</AccordionItem>
);
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, description, sections }) => {
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
const [openItems, setOpenItems] = React.useState<string[]>([]);
useEffect(() => {
@@ -100,7 +100,6 @@ const AssignPermissions = () => {
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
{/* <Text variant="medium">{ContainerCopyMessages.assignPermissions.crossAccountDescription}</Text> */}
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(

View File

@@ -20,7 +20,6 @@ const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAc
const OnlineCopyEnabled: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [loaderMessage, setLoaderMessage] = React.useState("");
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
@@ -76,21 +75,12 @@ const OnlineCopyEnabled: React.FC = () => {
setShowRefreshButton(false);
try {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
const sourAccountBeforeUpdate = await fetchDatabaseAccount(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
);
if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
}
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
@@ -130,7 +120,7 @@ const OnlineCopyEnabled: React.FC = () => {
return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={loaderMessage} />
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="info-message">
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp;
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">

View File

@@ -44,9 +44,10 @@ const useManagedIdentity = (
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
} finally {
setLoading(false);
}
}, [updateIdentityFn]);
}, [copyJobState, updateIdentityFn, setCopyJobState]);
return { loading, handleAddSystemIdentity };
};

View File

@@ -186,20 +186,15 @@ const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfi
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
const crossAccountSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const commonSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
const sourceAccountName = state.source?.account?.name || "";
const targetAccountName = state.target?.account?.name || "";
if (crossAccountSections.length > 0) {
if (commonSections.length > 0) {
groups.push({
id: "crossAccountConfigs",
title: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.title,
description: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.description(
sourceAccountName,
targetAccountName,
),
sections: crossAccountSections,
id: "commonConfigs",
title: ContainerCopyMessages.assignPermissions.commonConfiguration.title,
description: ContainerCopyMessages.assignPermissions.commonConfiguration.description,
sections: commonSections,
});
}
@@ -207,7 +202,7 @@ const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfi
groups.push({
id: "onlineConfigs",
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description(sourceAccountName),
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description,
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
});
}

View File

@@ -11,19 +11,25 @@ export function useDropdownOptions(
subscriptionOptions: DropdownOptionType[];
accountOptions: DropdownOptionType[];
} {
const subscriptionOptions =
subscriptions?.map((sub) => ({
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
})) || [];
const subscriptionOptions = React.useMemo(
() =>
subscriptions?.map((sub) => ({
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
})) || [],
[subscriptions],
);
const accountOptions =
accounts?.map((account) => ({
key: account.id,
text: account.name,
data: account,
})) || [];
const accountOptions = React.useMemo(
() =>
accounts?.map((account) => ({
key: account.id,
text: account.name,
data: account,
})) || [],
[accounts],
);
return { subscriptionOptions, accountOptions };
}
@@ -32,42 +38,45 @@ type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const handleSelectSourceAccount = (
type: "subscription" | "account",
data: (Subscription & DatabaseAccount) | undefined,
) => {
setCopyJobState((prevState: CopyJobContextState) => {
if (type === "subscription") {
return {
...prevState,
source: {
...prevState.source,
subscription: data || null,
account: null,
},
};
}
if (type === "account") {
return {
...prevState,
source: {
...prevState.source,
account: data || null,
},
};
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
};
const handleSelectSourceAccount = React.useCallback(
(type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
setCopyJobState((prevState: CopyJobContextState) => {
if (type === "subscription") {
return {
...prevState,
source: {
...prevState.source,
subscription: data || null,
account: null,
},
};
}
if (type === "account") {
return {
...prevState,
source: {
...prevState.source,
account: data || null,
},
};
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState, setValidationCache],
);
const handleMigrationTypeChange = React.useCallback((_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState: CopyJobContextState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
}, []);
const handleMigrationTypeChange = React.useCallback(
(_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState: CopyJobContextState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState, setValidationCache],
);
return { handleSelectSourceAccount, handleMigrationTypeChange };
}

View File

@@ -7,7 +7,7 @@ import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
import { useSourceAndTargetData } from "./memoizedData";
import { useMemoizedSourceAndTargetData } from "./memoizedData";
type SelectSourceAndTargetContainers = {
showAddCollectionPanel?: () => void;
@@ -16,35 +16,31 @@ type SelectSourceAndTargetContainers = {
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
useSourceAndTargetData(copyJobState);
useMemoizedSourceAndTargetData(copyJobState);
if (!source) {
return null;
}
const sourceDatabases = useDatabases(...sourceDbParams);
const sourceContainers = useDataContainers(...sourceContainerParams);
const targetDatabases = useDatabases(...targetDbParams);
const targetContainers = useDataContainers(...targetContainerParams);
const sourceDatabases = useDatabases(...sourceDbParams) || [];
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
const targetDatabases = useDatabases(...targetDbParams) || [];
const targetContainers = useDataContainers(...targetContainerParams) || [];
const sourceDatabaseOptions = React.useMemo(
() => sourceDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
() => sourceDatabases.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.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.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.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
[targetContainers],
);
const onDropdownChange = dropDownChangeHandler(setCopyJobState);
const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
return (
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>

View File

@@ -1,7 +1,8 @@
import React from "react";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
export function useSourceAndTargetData(copyJobState: CopyJobContextState) {
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
const { source, target } = copyJobState ?? {};
const selectedSourceAccount = source?.account;
const selectedTargetAccount = target?.account;
@@ -16,22 +17,27 @@ export function useSourceAndTargetData(copyJobState: CopyJobContextState) {
accountName: targetAccountName,
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams;
const sourceContainerParams = [
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
source?.databaseId,
"SQL",
] as DataContainerParams;
const targetDbParams = [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams;
const targetContainerParams = [
targetSubscriptionId,
targetResourceGroup,
targetAccountName,
target?.databaseId,
"SQL",
] as DataContainerParams;
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 };
}

View File

@@ -1,5 +1,4 @@
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import PropTypes from "prop-types";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
@@ -35,11 +34,7 @@ const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Completed]: "CompletedSolid",
};
export interface CopyJobStatusWithIconProps {
status: CopyJobStatusType;
}
const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo(({ status }) => {
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
const isSpinnerStatus = [
@@ -62,11 +57,6 @@ const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo((
<Text>{statusText}</Text>
</Stack>
);
});
CopyJobStatusWithIcon.displayName = "CopyJobStatusWithIcon";
CopyJobStatusWithIcon.propTypes = {
status: PropTypes.oneOf(Object.values(CopyJobStatusType)).isRequired,
};
export default CopyJobStatusWithIcon;

View File

@@ -1,6 +1,6 @@
import { ActionButton, Image } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import React, { memo } from "react";
import React from "react";
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
import * as Actions from "../../Actions/CopyJobActions";
import ContainerCopyMessages from "../../ContainerCopyMessages";
@@ -25,4 +25,4 @@ const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = ({ explorer }) => {
);
};
export default memo(CopyJobsNotFound);
export default CopyJobsNotFound;

View File

@@ -1,11 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/prop-types */
import {
ConstrainMode,
DetailsListLayoutMode,
DetailsRow,
IColumn,
IDetailsRowProps,
ScrollablePane,
ScrollbarVisibility,
ShimmeredDetailsList,
@@ -60,19 +58,22 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
setStartIndex(0);
};
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const columns: IColumn[] = React.useMemo(
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
);
const _handleRowClick = (job: CopyJobType) => {
const _handleRowClick = React.useCallback((job: CopyJobType) => {
openCopyJobDetailsPanel(job);
};
}, []);
const _onRenderRow = (props: IDetailsRowProps) => {
const _onRenderRow = React.useCallback((props: any) => {
return (
<div onClick={_handleRowClick.bind(null, props.item)}>
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
</div>
);
};
}, []);
return (
<div style={styles.container}>

View File

@@ -4,14 +4,13 @@ import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
import Explorer from "Explorer/Explorer";
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
import { convertToCamelCase, isEqual } from "../CopyJobUtils";
import { convertToCamelCase } from "../CopyJobUtils";
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
import CopyJobsList from "./Components/CopyJobsList";
const FETCH_INTERVAL_MS = 30 * 1000;
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
interface MonitorCopyJobsProps {
explorer: Explorer;
@@ -28,6 +27,8 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
const isUpdatingRef = React.useRef(false);
const isFirstFetchRef = React.useRef(true);
const indentLevels = React.useMemo<IndentLevel[]>(() => Array(7).fill({ level: 0, width: "100%" }), []);
const fetchJobs = React.useCallback(async () => {
if (isUpdatingRef.current) {
return;
@@ -40,7 +41,8 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
const response = await getCopyJobs();
setJobs((prevJobs) => {
return isEqual(prevJobs, response) ? prevJobs : response;
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.");
@@ -109,9 +111,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
return (
<Stack className="monitorCopyJobs flexContainer">
{loading && (
<ShimmerTree indentLevels={SHIMMER_INDENT_LEVELS} style={{ width: "100%", padding: "1rem 2.5rem" }} />
)}
{loading && <ShimmerTree indentLevels={indentLevels} style={{ width: "100%", padding: "1rem 2.5rem" }} />}
{error && (
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
{error}

View File

@@ -7,7 +7,7 @@ import {
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
@@ -35,6 +35,7 @@ import StoredProcedure from "./Tree/StoredProcedure";
import Trigger from "./Tree/Trigger";
import UserDefinedFunction from "./Tree/UserDefinedFunction";
import { useSelectedNode } from "./useSelectedNode";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
export interface CollectionContextMenuButtonParams {
databaseId: string;
@@ -60,6 +61,17 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
},
];
if (isFabricNative() && !userContext.fabricContext?.isReadOnly) {
const features = extractFeatures();
if (features?.enableRestoreContainer) {
items.push({
iconSrc: AddCollectionIcon,
onClick: () => openRestoreContainerDialog(),
label: `Restore ${getCollectionName()}`,
});
}
}
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
items.push({
iconSrc: DeleteDatabaseIcon,

View File

@@ -5,6 +5,12 @@ import { updateUserContext } from "../../../UserContext";
import { SettingsPane } from "./SettingsPane";
describe("Settings Pane", () => {
beforeEach(() => {
updateUserContext({
sessionId: "1234-5678",
});
});
it("should render Default properly", () => {
const wrapper = shallow(<SettingsPane explorer={null} />);
expect(wrapper).toMatchSnapshot();

View File

@@ -212,6 +212,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const styles = useStyles();
const explorerVersion = configContext.gitSha;
const sessionId: string = userContext.sessionId;
const isEmulator = configContext.platform === Platform.Emulator;
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
const showRetrySettings =
@@ -1227,6 +1228,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<div>{explorerVersion}</div>
</div>
</div>
<div className="settingsSection">
<div className="settingsSectionPart">
<div className="settingsSectionLabel">Session ID</div>
<div>{sessionId}</div>
</div>
</div>
</div>
</RightPaneForm>
);

View File

@@ -649,6 +649,22 @@ exports[`Settings Pane should render Default properly 1`] = `
<div />
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div
className="settingsSectionLabel"
>
Session ID
</div>
<div>
1234-5678
</div>
</div>
</div>
</div>
</RightPaneForm>
`;
@@ -958,6 +974,22 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
<div />
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div
className="settingsSectionLabel"
>
Session ID
</div>
<div>
1234-5678
</div>
</div>
</div>
</div>
</RightPaneForm>
`;

View File

@@ -286,7 +286,7 @@ export class CassandraAPIDataClient extends TableDataClient {
query,
paginationToken,
}),
beforeSend: this.setAuthorizationHeader as any,
beforeSend: this.setCommonHeaders as any,
cache: false,
});
shouldNotify &&
@@ -440,7 +440,7 @@ export class CassandraAPIDataClient extends TableDataClient {
keyspaceId: collection.databaseId,
tableId: collection.id(),
}),
beforeSend: this.setAuthorizationHeader as any,
beforeSend: this.setCommonHeaders as any,
cache: false,
})
.then(
@@ -482,7 +482,7 @@ export class CassandraAPIDataClient extends TableDataClient {
keyspaceId: collection.databaseId,
tableId: collection.id(),
}),
beforeSend: this.setAuthorizationHeader as any,
beforeSend: this.setCommonHeaders as any,
cache: false,
})
.then(
@@ -518,7 +518,7 @@ export class CassandraAPIDataClient extends TableDataClient {
resourceId: resourceId,
query: query,
}),
beforeSend: this.setAuthorizationHeader as any,
beforeSend: this.setCommonHeaders as any,
cache: false,
}).then(
(data: any) => {
@@ -547,7 +547,7 @@ export class CassandraAPIDataClient extends TableDataClient {
return cassandraEndpoint;
}
private setAuthorizationHeader: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => {
private setCommonHeaders: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => {
const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token);
@@ -555,6 +555,7 @@ export class CassandraAPIDataClient extends TableDataClient {
xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken);
}
xhr.setRequestHeader(Constants.HttpHeaders.sessionId, userContext.sessionId);
return true;
};

View File

@@ -105,6 +105,12 @@ const requestAndStoreAccessToken = async (): Promise<void> => {
});
};
export const openRestoreContainerDialog = (): void => {
if (configContext.platform === Platform.Fabric) {
sendCachedDataMessage(FabricMessageTypes.RestoreContainer, []);
}
};
/**
* Check token validity and schedule a refresh if necessary
* @param tokenTimestamp

View File

@@ -40,6 +40,7 @@ export type Features = {
readonly disableConnectionStringLogin: boolean;
readonly enableContainerCopy: boolean;
readonly enableCloudShell: boolean;
readonly enableRestoreContainer: boolean; // only for Fabric
// can be set via both flight and feature flag
autoscaleDefault: boolean;
@@ -111,6 +112,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
enableContainerCopy: "true" === get("enablecontainercopy"),
enableRestoreContainer: "true" === get("enablerestorecontainer"),
enableCloudShell: true,
};
}

View File

@@ -4,6 +4,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { useCarousel } from "hooks/useCarousel";
import { usePostgres } from "hooks/usePostgres";
import { v4 as uuidv4 } from "uuid";
import { AuthType } from "./AuthType";
import { DatabaseAccount } from "./Contracts/DataModels";
import { SubscriptionType } from "./Contracts/SubscriptionType";
@@ -118,6 +119,7 @@ export interface UserContext {
readonly dataPlaneRbacEnabled?: boolean;
readonly refreshCosmosClient?: boolean;
throughputBucketsEnabled?: boolean;
readonly sessionId: string;
}
export type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra" | "Postgres" | "VCoreMongo";
@@ -135,6 +137,7 @@ const userContext: UserContext = {
features,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
collectionCreationDefaults: CollectionCreationDefaults,
sessionId: uuidv4(), // Default sessionId - will be overwritten if provided by host
};
export function isAccountNewerThanThresholdInMs(createdAt: string, threshold: number) {

View File

@@ -43,6 +43,7 @@ describe("AuthorizationUtils", () => {
partitionKeyDefault: false,
partitionKeyDefault2: false,
notebooksDownBanner: false,
enableRestoreContainer: false,
},
});
};

View File

@@ -2,7 +2,7 @@ import { DatabaseAccount } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { buildArmUrl } from "Utils/arm/armUtils";
const apiVersion = "2025-05-01-preview";
const apiVersion = "2025-04-15";
export type FetchAccountDetailsParams = {
subscriptionId: string;
resourceGroupName: string;

View File

@@ -85,6 +85,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
userContext.features.phoenixNotebooks = true;
userContext.features.phoenixFeatures = true;
}
let explorer: Explorer;
if (platform === Platform.Hosted) {
explorer = await configureHosted();
@@ -927,6 +928,7 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
collectionCreationDefaults: inputs.defaultCollectionThroughput,
isTryCosmosDBSubscription: inputs.isTryCosmosDBSubscription,
feedbackPolicies: inputs.feedbackPolicies,
...(inputs.sessionId && { sessionId: inputs.sessionId }), // Remove conditional once Portal sends sessionId
});
if (inputs.isPostgresAccount) {