Compare commits

...

21 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
vchske
490309b403 Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding (#2254)
* Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding.

* Changed substringUtf method to be more accurate and added comments
2025-11-24 12:36:03 -08:00
sunghyunkang1111
0fac59967a Fix mongo database name handling (#2262) 2025-11-20 10:23:24 -08:00
Laurent Nguyen
c72d921866 fix: for fabric, don't display Querying offer for collection. (#2259)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-11-20 15:02:32 +01:00
BChoudhury-ms
125b1c86b7 Refactor Container Copy Jobs for Intra-account copy and Online operations (#2258)
* fix: for intra-account copy, validation screen should not visible

* fix: handle online operations using a button instead manual CLI commands

* reset validation cache on leaving of permission screen

* update same account logic

* fix: update job action menu list and permission screen messages

* uplift error handling to context level

* use of logError instead of console.error
2025-11-19 22:41:13 +05:30
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
Laurent Nguyen
beccab02e7 fix: error handling: better handle error.message undefined or '' case. (#2253)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-11-12 09:06:12 +01:00
sakshigupta12feb
a2e90b3a38 Removed unused old code from DE (#2251)
Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-11 20:08:45 +05:30
vchske
33a7412cf3 Removed broken feature to display PR url in DE console (#2249) 2025-11-07 12:36:03 -08:00
Nishtha Ahuja
6b150dbfa0 Revert "Index Advisor Tab on Execute Query (#2177)" (#2244)
This reverts commit abf4b3bd0f.

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-11-06 20:17:17 +05:30
vchske
bbdf0ce57e Updating Cosmos DB JS SDK to 4.7 (#2243) 2025-11-05 11:18:03 -08: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
73 changed files with 920 additions and 1215 deletions

96
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.5.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -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": {
@@ -391,9 +392,9 @@
"license": "0BSD"
},
"node_modules/@azure/cosmos": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.5.0.tgz",
"integrity": "sha512-JsTh4twb6FcwP7rJwxQiNZQ/LGtuF6gmciaxY9Rnp6/A325Lhsw/SH4R2ArpT0yCvozbZpweIwdPfUkXVBtp5w==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.7.0.tgz",
"integrity": "sha512-a8OV7E41u/ZDaaaDAFdqTTiJ7c82jZc/+ot3XzNCIIilR25NBB+1ixzWQOAgP8SHRUIKfaUl6wAPdTuiG9I66A==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
@@ -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

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.5.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -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": {

View File

@@ -7,7 +7,6 @@ const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
const githubPullRequestUrl = "https://github.com/Azure/cosmos-explorer/pull";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
const api = createProxyMiddleware({
@@ -57,11 +56,7 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
fetch(`${githubApiUrl}/pulls/${pr}`)
.then((response) => response.json())
.then(({ head: { ref, sha } }) => {
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
prUrl.hash = ref;
search.set("feature.pr", prUrl.href);
.then(({ head: { sha } }) => {
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
explorer.search = search.toString();

View File

@@ -93,7 +93,7 @@ export class CapabilityNames {
public static readonly EnableDataMasking: string = "EnableDataMasking";
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineCopyFeature";
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy";
}
export enum CapacityMode {
@@ -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

@@ -23,7 +23,10 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
};
export const getErrorMessage = (error: string | Error = ""): string => {
const errorMessage = typeof error === "string" ? error : error.message;
let errorMessage = typeof error === "string" ? error : error.message;
if (!errorMessage) {
errorMessage = JSON.stringify(error);
}
return replaceKnownError(errorMessage);
};

View File

@@ -0,0 +1,31 @@
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
import React from "react";
interface LoadingOverlayProps {
isLoading: boolean;
label: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
if (!isLoading) {
return null;
}
return (
<Overlay
styles={{
root: {
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
</Overlay>
);
};
export default LoadingOverlay;

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

@@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
try {
if (
userContext.authType === AuthType.AAD &&

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

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

View File

@@ -1,5 +1,7 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { logError } from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import {
cancel,
@@ -28,12 +30,12 @@ import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
export const openCreateCopyJobPanel = () => {
export const openCreateCopyJobPanel = (explorer: Explorer) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.createCopyJobPanelTitle,
<CreateCopyJobScreensProvider />,
<CreateCopyJobScreensProvider explorer={explorer} />,
"650px",
);
};
@@ -159,7 +161,8 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
onSuccess();
return response;
} catch (error) {
console.error("Error submitting create copy job:", error);
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
throw error;
}
};
@@ -198,8 +201,7 @@ export const updateCopyJobStatus = async (job: CopyJobType, action: string): Pro
pattern,
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
);
console.error(`Error updating copy job status: ${normalizedErrorMessage}`);
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
throw error;
}
};

View File

@@ -13,8 +13,8 @@ const rootStyle = {
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (

View File

@@ -9,7 +9,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -17,7 +17,7 @@ function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
iconSrc: AddIcon,
label: ContainerCopyMessages.createCopyJobButtonLabel,
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
onClick: Actions.openCreateCopyJobPanel,
onClick: Actions.openCreateCopyJobPanel.bind(null, explorer),
},
{
key: "refresh",
@@ -34,7 +34,7 @@ function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
label: ContainerCopyMessages.feedbackButtonLabel,
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
onClick: () => {
container.openContainerCopyFeedbackBlade();
explorer.openContainerCopyFeedbackBlade();
},
});
}
@@ -54,6 +54,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
export function getCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(container).map(btnMapper);
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
}

View File

@@ -36,6 +36,9 @@ export default {
databaseDropdownPlaceholder: "Select a database",
containerDropdownLabel: "Container",
containerDropdownPlaceholder: "Select a container",
createNewContainerSubHeading: "Select the properties for your container.",
createContainerButtonLabel: "Create a new container",
createContainerHeading: "Create new container",
// Preview and Create Screen
jobNameLabel: "Job name",
@@ -48,13 +51,24 @@ export default {
// Assign Permissions Screen
assignPermissions: {
description:
crossAccountDescription:
"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.`,
commonConfiguration: {
title: "Common configuration",
description: "Basic permissions required for copy operations",
},
onlineConfiguration: {
title: "Online copy configuration",
description: "Additional permissions required for online copy operations",
},
},
toggleBtn: {
onText: "On",
offText: "Off",
},
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
addManagedIdentity: {
title: "System-assigned managed identity enabled.",
description:
@@ -115,7 +129,7 @@ export default {
},
onlineCopyEnabled: {
title: "Online copy enabled",
description: (accountName: string) => `Use Azure CLI to enable Online copy on "${accountName}".`,
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",

View File

@@ -5,7 +5,7 @@ import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefStat
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
import { ContainerCopyProps } from "./Types/CopyJobTypes";
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ explorer }) => {
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
useEffect(() => {
if (monitorCopyJobsRef.current) {
@@ -14,8 +14,8 @@ const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
}, [monitorCopyJobsRef.current]);
return (
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
<CopyJobCommandBar container={container} />
<MonitorCopyJobs ref={monitorCopyJobsRef} />
<CopyJobCommandBar explorer={explorer} />
<MonitorCopyJobs ref={monitorCopyJobsRef} explorer={explorer} />
</div>
);
};

View File

@@ -1,3 +1,5 @@
import Explorer from "Explorer/Explorer";
import { Subscription } from "Contracts/DataModels";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
@@ -14,6 +16,7 @@ export const useCopyJobContext = (): CopyJobContextProviderType => {
interface CopyJobContextProviderProps {
children: React.ReactNode;
explorer: Explorer;
}
const getInitialCopyJobState = (): CopyJobContextState => {
@@ -21,8 +24,10 @@ const getInitialCopyJobState = (): CopyJobContextState => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null,
account: null,
subscription: {
subscriptionId: userContext.subscriptionId || "",
} as Subscription,
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
@@ -39,16 +44,24 @@ const getInitialCopyJobState = (): CopyJobContextState => {
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
const [contextError, setContextError] = React.useState<string | null>(null);
const resetCopyJobState = () => {
setCopyJobState(getInitialCopyJobState());
};
return (
<CopyJobContext.Provider value={{ copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
{props.children}
</CopyJobContext.Provider>
);
const contextValue: CopyJobContextProviderType = {
contextError,
setContextError,
copyJobState,
setCopyJobState,
flow,
setFlow,
resetCopyJobState,
explorer: props.explorer,
};
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
};
export default CopyJobContextProvider;

View File

@@ -1,5 +1,5 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobErrorType } from "./Types/CopyJobTypes";
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
@@ -106,7 +106,7 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) {
return null;
}
const pattern = new RegExp(
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB/databaseAccounts/([^/]+)",
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)",
"i",
);
const matches = accountId.match(pattern);
@@ -114,3 +114,58 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) {
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
return { subscriptionId, resourceGroup, accountName };
}
export function getContainerIdentifiers(container: CopyJobContextState["source"] | CopyJobContextState["target"]) {
return {
accountId: container?.account?.id || "",
databaseId: container?.databaseId || "",
containerId: container?.containerId || "",
};
}
export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean {
const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId);
const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId);
return (
sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId &&
sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup &&
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
);
}
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
if (prevJobs.length !== newJobs.length) {
return false;
}
return prevJobs.every((prevJob: CopyJobType) => {
const newJob = newJobs.find((job) => job.Name === prevJob.Name);
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status;
});
}
const truncateLength = 5;
const truncateName = (name: string, length: number = truncateLength): string => {
return name.length <= length ? name : name.slice(0, length);
};
export function getDefaultJobName(
selectedDatabaseAndContainers: {
sourceDatabaseName?: string;
sourceContainerName?: string;
targetDatabaseName?: string;
targetContainerName?: string;
}[],
): string {
if (selectedDatabaseAndContainers.length === 1) {
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
selectedDatabaseAndContainers[0];
const timestamp = new Date().getTime().toString();
const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`;
const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`;
return `${sourcePart}_${targetPart}_${timestamp}`;
}
return "";
}

View File

@@ -1,5 +1,6 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React, { useCallback } from "react";
import { logError } from "../../../../../Common/Logger";
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
@@ -21,7 +22,7 @@ type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
const [loading, setLoading] = React.useState(false);
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const handleAddReadPermission = useCallback(async () => {
@@ -48,11 +49,14 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
}));
}
} catch (error) {
console.error("Error assigning read permission to default identity:", error);
const errorMessage =
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]);
}, [copyJobState, setCopyJobState, setContextError]);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>

View File

@@ -6,8 +6,10 @@ import WarningIcon from "../../../../../../images/warning.svg";
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { isIntraAccountCopy } from "../../../CopyJobUtils";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
<AccordionItem key={id} value={id} disabled={disabled}>
@@ -29,35 +31,91 @@ const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Compo
</AccordionItem>
);
const AssignPermissions = () => {
const { copyJobState } = useCopyJobContext();
const permissionSections = usePermissionSections(copyJobState);
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
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 firstIncompleteSection = sections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [permissionSections]);
}, [sections]);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
<span>{ContainerCopyMessages.assignPermissions.description}</span>
{permissionSections?.length === 0 ? (
<Stack
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "#fafafa",
border: "1px solid #e1e1e1",
borderRadius: 8,
padding: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
}}
>
<Stack tokens={{ childrenGap: 5 }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
</Stack>
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{sections.map((section) => (
<PermissionSection key={section.id} {...section} />
))}
</Accordion>
</Stack>
);
};
const AssignPermissions = () => {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const { copyJobState } = useCopyJobContext();
const permissionGroups = usePermissionSections(copyJobState);
const totalSectionsCount = React.useMemo(
() => permissionGroups.reduce((total, group) => total + group.sections.length, 0),
[permissionGroups],
);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[copyJobState.migrationType],
);
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
useEffect(() => {
return () => {
setValidationCache(new Map<string, boolean>());
};
}, []);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
)
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
</Text>
{totalSectionsCount === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{permissionSections.map((section) => (
<PermissionSection key={section.id} {...section} />
<Stack tokens={{ childrenGap: 25 }}>
{permissionGroups.map((group) => (
<PermissionGroup key={group.id} {...group} />
))}
</Accordion>
</Stack>
)}
</Stack>
);

View File

@@ -2,6 +2,10 @@ import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CapabilityNames } from "../../../../../Common/Constants";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
@@ -19,8 +23,10 @@ const OnlineCopyEnabled: React.FC = () => {
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const selectedSourceAccount = source?.account;
const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? [];
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
@@ -38,16 +44,24 @@ const OnlineCopyEnabled: React.FC = () => {
setLoading(false);
}
} catch (error) {
console.error("Error fetching source account after enabling online copy:", error);
setLoading(false);
const errorMessage =
error.message || "Error fetching source account after enabling online copy. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount");
setContextError(errorMessage);
clearAccountFetchInterval();
}
};
const clearIntervalAndShowRefresh = () => {
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
@@ -56,18 +70,42 @@ const OnlineCopyEnabled: React.FC = () => {
handleFetchAccount();
};
const handleOnlineCopyEnable = async () => {
setLoading(true);
setShowRefreshButton(false);
try {
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
},
});
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
10 * 60 * 1000,
);
} catch (error) {
const errorMessage = error.message || "Failed to enable online copy feature. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
setContextError(errorMessage);
setLoading(false);
}
};
React.useEffect(() => {
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
15 * 60 * 1000,
);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
@@ -82,6 +120,7 @@ const OnlineCopyEnabled: React.FC = () => {
return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<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">
@@ -89,32 +128,7 @@ const OnlineCopyEnabled: React.FC = () => {
</Link>
</Stack.Item>
<Stack.Item>
<pre style={{ backgroundColor: "#f5f5f5", padding: "10px", borderRadius: "4px", overflow: "auto" }}>
<code>
{`# Set shell variables
$resourceGroupName = <azure_resource_group>
$accountName = <azure_cosmos_db_account_name>
$EnableOnlineContainerCopy = "EnableOnlineContainerCopy"
# List down existing capabilities of your account
$cosmosdb = az cosmosdb show --resource-group $resourceGroupName --name $accountName
$capabilities = (($cosmosdb | ConvertFrom-Json).capabilities)
# Append EnableOnlineContainerCopy capability in the list of capabilities
$capabilitiesToAdd = @()
foreach ($item in $capabilities) {
$capabilitiesToAdd += $item.name
}
$capabilitiesToAdd += $EnableOnlineContainerCopy
# Update Cosmos DB account
az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourceGroupName`}
</code>
</pre>
</Stack.Item>
{showRefreshButton && (
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
@@ -122,8 +136,16 @@ az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourc
onClick={handleRefresh}
disabled={loading}
/>
</Stack.Item>
)}
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={handleOnlineCopyEnable}
/>
)}
</Stack.Item>
</Stack>
);
};

View File

@@ -2,6 +2,8 @@ import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React, { useEffect, useRef, useState } from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
@@ -63,23 +65,30 @@ const PointInTimeRestore: React.FC = () => {
setLoading(false);
}
} catch (error) {
console.error("Error fetching source account after Point-in-Time Restore:", error);
setLoading(false);
const errorMessage =
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
clearAccountFetchInterval();
}
};
const clearIntervalAndShowRefresh = () => {
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = () => {
const handleRefresh = async () => {
setLoading(true);
handleFetchAccount();
await handleFetchAccount();
setLoading(false);
};
const openWindowAndMonitor = () => {
@@ -95,12 +104,13 @@ const PointInTimeRestore: React.FC = () => {
() => {
clearIntervalAndShowRefresh();
},
15 * 60 * 1000,
10 * 60 * 1000,
);
};
return (
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="toggle-label">
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
{tooltipContent && (

View File

@@ -1,5 +1,6 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { useCallback, useState } from "react";
import { logError } from "../../../../../../Common/Logger";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
@@ -19,7 +20,7 @@ interface UseManagedIdentityUpdaterReturn {
const useManagedIdentity = (
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
): UseManagedIdentityUpdaterReturn => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [loading, setLoading] = useState<boolean>(false);
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
@@ -40,7 +41,9 @@ const useManagedIdentity = (
}));
}
} catch (error) {
console.error("Error enabling system-assigned managed identity:", error);
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
} finally {
setLoading(false);
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { CapabilityNames } from "../../../../../../Common/Constants";
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
import { getAccountDetailsFromResourceId, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
@@ -26,6 +26,13 @@ export interface PermissionSectionConfig {
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
}
export interface PermissionGroupConfig {
id: string;
title: string;
description: string;
sections: PermissionSectionConfig[];
}
export const SECTION_IDS = {
addManagedIdentity: "addManagedIdentity",
defaultManagedIdentity: "defaultManagedIdentity",
@@ -127,24 +134,81 @@ export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinition
}
/**
* Returns the permission sections configuration for the Assign Permissions screen.
* Memoizes derived values for performance and decouples logic for testability.
* Validates sections within a group sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => {
const sourceAccountId = state?.source?.account?.id || "";
const targetAccountId = state?.target?.account?.id || "";
const validateSectionsInGroup = async (
sections: PermissionSectionConfig[],
state: CopyJobContextState,
validationCache: Map<string, boolean>,
): Promise<PermissionSectionConfig[]> => {
const result: PermissionSectionConfig[] = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (validationCache.has(section.id) && validationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
validationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
// Mark remaining sections in this group as incomplete
for (let j = i + 1; j < sections.length; j++) {
result.push({ ...sections[j], completed: false });
}
break;
}
} else {
validationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return result;
};
/**
* Returns the permission groups configuration for the Assign Permissions screen.
* Groups validate independently but sections within each group validate sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => {
const sourceAccount = getContainerIdentifiers(state.source);
const targetAccount = getContainerIdentifiers(state.target);
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
const [permissionGroups, setPermissionGroups] = useState<PermissionGroupConfig[] | null>(null);
const isValidatingRef = useRef(false);
const sectionToValidate = useMemo(() => {
const baseSections = sourceAccountId === targetAccountId ? [] : [...PERMISSION_SECTIONS_CONFIG];
if (state.migrationType === CopyJobMigrationType.Online) {
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
const commonSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
if (commonSections.length > 0) {
groups.push({
id: "commonConfigs",
title: ContainerCopyMessages.assignPermissions.commonConfiguration.title,
description: ContainerCopyMessages.assignPermissions.commonConfiguration.description,
sections: commonSections,
});
}
return baseSections;
}, [sourceAccountId, targetAccountId, state.migrationType]);
if (state.migrationType === CopyJobMigrationType.Online) {
groups.push({
id: "onlineConfigs",
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description,
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
});
}
return groups;
}, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]);
const memoizedValidationCache = useMemo(() => {
if (state.migrationType === CopyJobMigrationType.Offline) {
@@ -155,52 +219,39 @@ const usePermissionSections = (state: CopyJobContextState): PermissionSectionCon
}, [state.migrationType]);
useEffect(() => {
const validateSections = async () => {
const validateGroups = 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];
// Validate all groups independently (in parallel)
const validatedGroups = await Promise.all(
groupsToValidate.map(async (group) => {
const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache);
if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
newValidationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
for (let j = i + 1; j < sectionToValidate.length; j++) {
result.push({ ...sectionToValidate[j], completed: false });
}
break;
}
} else {
newValidationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return {
...group,
sections: validatedSections,
};
}),
);
setValidationCache(newValidationCache);
setPermissionSections(result);
setPermissionGroups(validatedGroups);
isValidatingRef.current = false;
};
validateSections();
validateGroups();
return () => {
isValidatingRef.current = false;
};
}, [state, sectionToValidate]);
}, [state, groupsToValidate]);
return permissionSections ?? [];
return permissionGroups ?? [];
};
export default usePermissionSections;

View File

@@ -2,6 +2,8 @@
/* eslint-disable react/display-name */
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
import React from "react";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
interface PopoverContainerProps {
isLoading?: boolean;
@@ -19,17 +21,13 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<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}
/>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack>
</Stack>

View File

@@ -0,0 +1,53 @@
import { Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { useSidePanel } from "hooks/useSidePanel";
import { produce } from "immer";
import React, { useCallback, useEffect } from "react";
import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
type AddCollectionPanelWrapperProps = {
explorer?: Explorer;
goBack?: () => void;
};
const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapperProps> = ({ explorer, goBack }) => {
const { setCopyJobState } = useCopyJobContext();
useEffect(() => {
const sidePanelStore = useSidePanel.getState();
if (sidePanelStore.headerText !== ContainerCopyMessages.createContainerHeading) {
sidePanelStore.setHeaderText(ContainerCopyMessages.createContainerHeading);
}
return () => {
sidePanelStore.setHeaderText(ContainerCopyMessages.createCopyJobPanelTitle);
};
}, []);
const handleAddCollectionSuccess = useCallback(
(collectionData: { databaseId: string; collectionId: string }) => {
setCopyJobState(
produce((state) => {
state.target.databaseId = collectionData.databaseId;
state.target.containerId = collectionData.collectionId;
}),
);
goBack?.();
},
[goBack],
);
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
</Stack.Item>
</Stack>
);
};
export default AddCollectionPanelWrapper;

View File

@@ -1,5 +1,6 @@
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
import NavigationControls from "./Components/NavigationControls";
@@ -12,27 +13,27 @@ const CreateCopyJobScreens: React.FC = () => {
handlePrevious,
handleCancel,
primaryBtnText,
error,
setError,
showAddCollectionPanel,
} = useCopyJobNavigation();
const { contextError, setContextError } = useCopyJobContext();
return (
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
<Stack.Item className="createCopyJobScreensContent">
{error && (
{contextError && (
<MessageBar
className="createCopyJobErrorMessageBar"
messageBarType={MessageBarType.blocked}
isMultiline={false}
onDismiss={() => setError(null)}
onDismiss={() => setContextError(null)}
dismissButtonAriaLabel="Close"
truncated={true}
overflowButtonAriaLabel="See more"
>
{error}
{contextError}
</MessageBar>
)}
{currentScreen?.component}
{React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })}
</Stack.Item>
<Stack.Item className="createCopyJobScreensFooter">
<NavigationControls

View File

@@ -1,10 +1,11 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import CopyJobContextProvider from "../../Context/CopyJobContext";
import CreateCopyJobScreens from "./CreateCopyJobScreens";
const CreateCopyJobScreensProvider = () => {
const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => {
return (
<CopyJobContextProvider>
<CopyJobContextProvider explorer={explorer}>
<CreateCopyJobScreens />
</CopyJobContextProvider>
);

View File

@@ -1,8 +1,9 @@
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow";
import React from "react";
import React, { useEffect } from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getDefaultJobName } from "../../../CopyJobUtils";
import FieldRow from "../Components/FieldRow";
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
const PreviewCopyJob: React.FC = () => {
@@ -16,6 +17,11 @@ const PreviewCopyJob: React.FC = () => {
targetContainerName: copyJobState.target?.containerId,
},
];
useEffect(() => {
onJobNameChange(undefined, getDefaultJobName(selectedDatabaseAndContainers));
}, []);
const jobName = copyJobState.jobName;
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {

View File

@@ -27,4 +27,5 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -25,4 +25,5 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -16,6 +16,7 @@ import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils
const SelectAccount = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const selectedSourceAccountId = copyJobState?.source?.account?.id;
const subscriptions: Subscription[] = useSubscriptions();
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
@@ -38,7 +39,7 @@ const SelectAccount = React.memo(() => {
<AccountDropdown
options={accountOptions}
selectedKey={copyJobState?.source?.account?.id}
selectedKey={selectedSourceAccountId}
disabled={!selectedSubscriptionId}
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
/>

View File

@@ -2,6 +2,7 @@ import React from "react";
import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
export function useDropdownOptions(
subscriptions: Subscription[],
@@ -36,6 +37,7 @@ export function useDropdownOptions(
type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const handleSelectSourceAccount = React.useCallback(
(type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
setCopyJobState((prevState: CopyJobContextState) => {
@@ -60,8 +62,9 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState],
[setCopyJobState, setValidationCache],
);
const handleMigrationTypeChange = React.useCallback(
@@ -70,8 +73,9 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState],
[setCopyJobState, setValidationCache],
);
return { handleSelectSourceAccount, handleMigrationTypeChange };

View File

@@ -9,7 +9,11 @@ import { DatabaseContainerSection } from "./components/DatabaseContainerSection"
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
import { useMemoizedSourceAndTargetData } from "./memoizedData";
const SelectSourceAndTargetContainers = () => {
type SelectSourceAndTargetContainers = {
showAddCollectionPanel?: () => void;
};
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
useMemoizedSourceAndTargetData(copyJobState);
@@ -62,6 +66,7 @@ const SelectSourceAndTargetContainers = () => {
selectedContainer={target?.containerId}
containerDisabled={!target?.databaseId}
containerOnChange={onDropdownChange("targetContainer")}
handleOnDemandCreateContainer={showAddCollectionPanel}
/>
</Stack>
);

View File

@@ -1,4 +1,4 @@
import { Dropdown, Stack } from "@fluentui/react";
import { ActionButton, Dropdown, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes";
@@ -14,6 +14,7 @@ export const DatabaseContainerSection = ({
selectedContainer,
containerDisabled,
containerOnChange,
handleOnDemandCreateContainer,
}: DatabaseContainerSectionProps) => (
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
<label className="subHeading">{heading}</label>
@@ -29,15 +30,22 @@ export const DatabaseContainerSection = ({
/>
</FieldRow>
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
options={containerOptions}
required
disabled={!!containerDisabled}
selectedKey={selectedContainer}
onChange={containerOnChange}
/>
<Stack>
<Dropdown
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
options={containerOptions}
required
disabled={!!containerDisabled}
selectedKey={selectedContainer}
onChange={containerOnChange}
/>
{handleOnDemandCreateContainer && (
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
{ContainerCopyMessages.createContainerButtonLabel}
</ActionButton>
)}
</Stack>
</FieldRow>
</Stack>
);

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useReducer, useState } from "react";
import { useSidePanel } from "../../../../hooks/useSidePanel";
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils";
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
@@ -33,12 +34,15 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt
export function useCopyJobNavigation() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { copyJobState, resetCopyJobState } = useCopyJobContext();
const screens = useCreateCopyJobScreensList();
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
const { validationCache: cache } = useCopyJobPrerequisitesCache();
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
const handlePrevious = useCallback(() => {
dispatch({ type: "PREVIOUS" });
}, [dispatch]);
const screens = useCreateCopyJobScreensList(handlePrevious);
const currentScreenKey = state.screenHistory[state.screenHistory.length - 1];
const currentScreen = screens.find((screen) => screen.key === currentScreenKey);
@@ -51,7 +55,9 @@ export function useCopyJobNavigation() {
}, [currentScreen.key, copyJobState, cache, isLoading]);
const primaryBtnText = useMemo(() => {
if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
return "Create";
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
return "Copy";
}
return "Next";
@@ -65,24 +71,13 @@ export function useCopyJobNavigation() {
useSidePanel.getState().closeSidePanel();
}, []);
const getContainerIdentifiers = (container: typeof copyJobState.source | typeof copyJobState.target) => ({
accountId: container?.account?.id || "",
databaseId: container?.databaseId || "",
containerId: container?.containerId || "",
});
const isSameAccount = (
sourceIds: ReturnType<typeof getContainerIdentifiers>,
targetIds: ReturnType<typeof getContainerIdentifiers>,
) => sourceIds.accountId === targetIds.accountId;
const areContainersIdentical = () => {
const { source, target } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
const targetIds = getContainerIdentifiers(target);
return (
isSameAccount(sourceIds, targetIds) &&
isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) &&
sourceIds.databaseId === targetIds.databaseId &&
sourceIds.containerId === targetIds.containerId
);
@@ -90,9 +85,10 @@ export function useCopyJobNavigation() {
const shouldNotShowPermissionScreen = () => {
const { source, target, migrationType } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
const targetIds = getContainerIdentifiers(target);
return (
migrationType === CopyJobMigrationType.Offline &&
isSameAccount(getContainerIdentifiers(source), getContainerIdentifiers(target))
migrationType === CopyJobMigrationType.Offline && isIntraAccountCopy(sourceIds.accountId, targetIds.accountId)
);
};
@@ -105,19 +101,40 @@ export function useCopyJobNavigation() {
error instanceof Error
? error.message || "Failed to create copy job. Please try again later."
: "Failed to create copy job. Please try again later.";
setError(errorMessage);
setContextError(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleAddCollectionPanelSubmit = () => {
const form = document.getElementById("panelContainer") as HTMLFormElement;
if (form) {
const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
});
form.dispatchEvent(submitEvent);
}
};
const showAddCollectionPanel = useCallback(() => {
dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.CreateCollection });
}, [dispatch]);
const handlePrimary = useCallback(() => {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
handleAddCollectionPanelSubmit();
return;
}
if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) {
setError("Source and destination containers cannot be the same. Please select different containers to proceed.");
setContextError(
"Source and destination containers cannot be the same. Please select different containers to proceed.",
);
return;
}
setError(null);
setContextError(null);
const transitions = {
[SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen()
? SCREEN_KEYS.SelectSourceAndTargetContainers
@@ -134,10 +151,6 @@ export function useCopyJobNavigation() {
}
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
const handlePrevious = useCallback(() => {
dispatch({ type: "PREVIOUS" });
}, []);
return {
currentScreen,
isPrimaryDisabled,
@@ -145,8 +158,7 @@ export function useCopyJobNavigation() {
handlePrimary,
handlePrevious,
handleCancel,
showAddCollectionPanel,
primaryBtnText,
error,
setError,
};
}

View File

@@ -1,11 +1,14 @@
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { CopyJobContextState } from "../../Types/CopyJobTypes";
import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions";
import AddCollectionPanelWrapper from "../Screens/CreateContainer/AddCollectionPanelWrapper";
import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob";
import SelectAccount from "../Screens/SelectAccount/SelectAccount";
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
const SCREEN_KEYS = {
CreateCollection: "CreateCollection",
SelectAccount: "SelectAccount",
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
PreviewCopyJob: "PreviewCopyJob",
@@ -23,7 +26,9 @@ type Screen = {
validations: Validation[];
};
function useCreateCopyJobScreensList() {
function useCreateCopyJobScreensList(goBack: () => void): Screen[] {
const { explorer } = useCopyJobContext();
return React.useMemo<Screen[]>(
() => [
{
@@ -50,13 +55,18 @@ function useCreateCopyJobScreensList() {
},
],
},
{
key: SCREEN_KEYS.CreateCollection,
component: <AddCollectionPanelWrapper explorer={explorer} goBack={goBack} />,
validations: [],
},
{
key: SCREEN_KEYS.PreviewCopyJob,
component: <PreviewCopyJob />,
validations: [
{
validate: (state: CopyJobContextState) =>
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)),
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-._]+$/.test(state?.jobName)),
message: "Please enter a job name to proceed",
},
],
@@ -80,7 +90,7 @@ function useCreateCopyJobScreensList() {
],
},
],
[],
[explorer],
);
}

View File

@@ -11,7 +11,14 @@ interface CopyJobActionMenuProps {
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) {
if (
[
CopyJobStatusType.Completed,
CopyJobStatusType.Cancelled,
CopyJobStatusType.Failed,
CopyJobStatusType.Faulted,
].includes(job.Status)
) {
return null;
}
@@ -55,7 +62,7 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
[CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status)
) {
const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume);
if (job.Mode === CopyJobMigrationType.Online) {
if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) {
filteredItems.push({
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
@@ -67,7 +74,7 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
return filteredItems;
}
if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) {
if ([CopyJobStatusType.Skipped].includes(job.Status)) {
return baseItems.filter((item) => item.key === CopyJobActions.resume);
}

View File

@@ -1,18 +1,24 @@
import { ActionButton, Image } from "@fluentui/react";
import React, { useCallback } from "react";
import Explorer from "Explorer/Explorer";
import React from "react";
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
import * as Actions from "../../Actions/CopyJobActions";
import ContainerCopyMessages from "../../ContainerCopyMessages";
interface CopyJobsNotFoundProps {}
interface CopyJobsNotFoundProps {
explorer: Explorer;
}
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = () => {
const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []);
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = ({ explorer }) => {
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}>
<ActionButton
allowDisabledFocus
className="createCopyJobButton"
onClick={Actions.openCreateCopyJobPanel.bind(null, explorer)}
>
{ContainerCopyMessages.createCopyJobButtonText}
</ActionButton>
</div>

View File

@@ -1,6 +1,7 @@
/* eslint-disable react/display-name */
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
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 } from "../CopyJobUtils";
@@ -11,13 +12,15 @@ import CopyJobsList from "./Components/CopyJobsList";
const FETCH_INTERVAL_MS = 30 * 1000;
interface MonitorCopyJobsProps {}
interface MonitorCopyJobsProps {
explorer: Explorer;
}
export interface MonitorCopyJobsRef {
refreshJobList: () => void;
}
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({ explorer }, ref) => {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
@@ -96,15 +99,15 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_p
[],
);
const memoizedJobsList = React.useMemo(() => {
const renderJobsList = () => {
if (loading) {
return null;
}
if (jobs.length > 0) {
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
}
return <CopyJobsNotFound />;
}, [jobs, loading, handleActionClick]);
return <CopyJobsNotFound explorer={explorer} />;
};
return (
<Stack className="monitorCopyJobs flexContainer">
@@ -114,7 +117,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_p
{error}
</MessageBar>
)}
{memoizedJobsList}
{renderJobsList()}
</Stack>
);
});

View File

@@ -6,7 +6,7 @@ import Explorer from "../../Explorer";
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
export interface ContainerCopyProps {
container: Explorer;
explorer: Explorer;
}
export type CopyJobCommandBarBtnType = {
@@ -48,6 +48,7 @@ export interface DatabaseContainerSectionProps {
selectedContainer: string;
containerDisabled?: boolean;
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
handleOnDemandCreateContainer?: () => void;
}
export interface CopyJobContextState {
@@ -73,11 +74,14 @@ export interface CopyJobFlowType {
}
export interface CopyJobContextProviderType {
contextError: string | null;
setContextError: React.Dispatch<React.SetStateAction<string | null>>;
flow: CopyJobFlowType;
setFlow: React.Dispatch<React.SetStateAction<CopyJobFlowType>>;
copyJobState: CopyJobContextState | null;
setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>;
resetCopyJobState: () => void;
explorer?: Explorer;
}
export type CopyJobType = {

View File

@@ -19,6 +19,10 @@
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
label {
padding: 0;
@@ -59,6 +63,7 @@
}
}
.popover-container {
border-radius: 6px;
button[disabled] {
cursor: not-allowed;
opacity: 0.8;
@@ -66,7 +71,7 @@
}
.foreground {
z-index: 10;
background-color: white;
background-color: #f9f9f9;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
@@ -75,6 +80,24 @@
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: @LinkColor;
&:focus {
outline: none;
}
}
/* Create collection panel */
.panelFormWrapper .panelMainContent {
padding: 0;
}
.createCopyJobScreensFooter {
margin-top: 50px;
}
}
.monitorCopyJobs {
@@ -118,8 +141,9 @@
.jobNameLink {
color: @LinkColor;
text-decoration: underline;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}

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

@@ -1,8 +1,5 @@
import { IndexingPolicy } from "@azure/cosmos";
import { act } from "@testing-library/react";
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import ko from "knockout";
import React from "react";
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
@@ -447,47 +444,3 @@ describe("SettingsComponent", () => {
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});
describe("SettingsComponent - indexing policy subscription", () => {
const baseProps: SettingsComponentProps = {
settingsTab: new CollectionSettingsTabV2({
collection: collection,
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings",
tabPath: "",
node: undefined,
}),
};
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
const containerId = collection.id();
const mockIndexingPolicy: IndexingPolicy = {
automatic: false,
indexingMode: "lazy",
includedPaths: [{ path: "/foo/*" }],
excludedPaths: [{ path: "/bar/*" }],
compositeIndexes: [],
spatialIndexes: [],
vectorIndexes: [],
fullTextIndexes: [],
};
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const instance = wrapper.instance() as SettingsComponent;
await act(async () => {
useIndexingPolicyStore.setState({
indexingPolicies: {
[containerId]: mockIndexingPolicy,
},
});
});
wrapper.update();
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
});
});

View File

@@ -13,7 +13,6 @@ import {
ThroughputBucketsComponent,
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
@@ -74,6 +73,7 @@ import {
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private totalThroughputUsed: number;
private throughputBucketsEnabled: boolean;
public mongoDBCollectionResource: MongoDBCollectionResource;
private unsubscribe: () => void;
constructor(props: SettingsComponentProps) {
super(props);
@@ -318,19 +318,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (this.props.settingsTab.isActive()) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
this.unsubscribe = useIndexingPolicyStore.subscribe(
() => {
this.refreshCollectionData();
},
(state) => state.indexingPolicies[this.collection.id()],
);
this.refreshCollectionData();
}
componentWillUnmount(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
@@ -860,6 +849,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
{ name: "name_of_property", query: "query_to_compute_property" },
] as DataModels.ComputedProperties;
}
const throughputBuckets = this.offer?.throughputBuckets;
return {
@@ -1019,31 +1009,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
startKey,
);
};
private refreshCollectionData = async (): Promise<void> => {
const containerId = this.collection.id();
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
const latestCollection: DataModels.IndexingPolicy = {
automatic: rawPolicy?.automatic ?? true,
indexingMode: rawPolicy?.indexingMode ?? "consistent",
includedPaths: rawPolicy?.includedPaths ?? [],
excludedPaths: rawPolicy?.excludedPaths ?? [],
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
};
this.collection.rawDataModel.indexingPolicy = latestCollection;
this.setState({
indexingPolicyContent: latestCollection,
indexingPolicyContentBaseline: latestCollection,
});
};
private saveCollectionSettings = async (startKey: number): Promise<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
@@ -1283,6 +1252,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">

View File

@@ -1,10 +1,10 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import * as React from "react";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { MessageBar, MessageBarType } from "@fluentui/react";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {

View File

@@ -73,16 +73,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -176,16 +166,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -260,25 +240,17 @@ exports[`SettingsComponent renders 1`] = `
indexingPolicyContent={
{
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
}
}
indexingPolicyContentBaseline={
{
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
}
}
isVectorSearchEnabled={false}
@@ -352,16 +324,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -503,16 +465,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{

View File

@@ -16,7 +16,6 @@ import InfoIcon from "../../../../images/info_color.svg";
import LoadingIcon from "../../../../images/loading.svg";
import WarningIcon from "../../../../images/warning.svg";
import { ClientDefaults, KeyCodes } from "../../../Common/Constants";
import { userContext } from "../../../UserContext";
import { useNotificationConsole } from "../../../hooks/useNotificationConsole";
import { ConsoleData, ConsoleDataType } from "./ConsoleData";
@@ -127,7 +126,6 @@ export class NotificationConsoleComponent extends React.Component<
<span className="numWarningItems">{numWarningItems}</span>
</span>
</span>
{userContext.features.pr && <PrPreview pr={userContext.features.pr} />}
<span className="consoleSplitter" />
<span className="headerStatus">
<span className="headerStatusEllipsis" aria-live="assertive" aria-atomic="true">
@@ -293,21 +291,6 @@ export class NotificationConsoleComponent extends React.Component<
};
}
const PrPreview = (props: { pr: string }) => {
const url = new URL(props.pr);
const [, ref] = url.hash.split("#");
url.hash = "";
return (
<>
<span className="consoleSplitter" />
<a target="_blank" rel="noreferrer" href={url.href} style={{ marginRight: "1em", fontWeight: "bold" }}>
{ref}
</a>
</>
);
};
export const NotificationConsole: React.FC = () => {
const setIsExpanded = useNotificationConsole((state) => state.setIsExpanded);
const isExpanded = useNotificationConsole((state) => state.isExpanded);

View File

@@ -65,6 +65,8 @@ export interface AddCollectionPanelProps {
explorer: Explorer;
databaseId?: string;
isQuickstart?: boolean;
isCopyJobFlow?: boolean;
onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void;
}
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
@@ -975,7 +977,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
</div>
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
{!this.props.isCopyJobFlow && (
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
)}
{this.state.isExecuting && (
<div>
@@ -1415,8 +1419,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
this.setState({ isExecuting: false });
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
if (this.props.isCopyJobFlow && this.props.onSubmitSuccess) {
this.props.onSubmitSuccess({ databaseId, collectionId });
} else {
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
}
} catch (error) {
const errorMessage: string = getErrorMessage(error);
this.setState({ isExecuting: false, errorMessage, showErrorDetails: true });

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

@@ -1,107 +0,0 @@
import { CircleFilled } from "@fluentui/react-icons";
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
import * as React from "react";
// SDK response format
export interface IndexMetricsResponse {
UtilizedIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
PotentialIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
}
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
included: IIndexMetric[];
notIncluded: IIndexMetric[];
} {
const included: IIndexMetric[] = [];
const notIncluded: IIndexMetric[] = [];
// Process UtilizedIndexes (Included in Current Policy)
if (indexMetrics.UtilizedIndexes) {
// Single indexes
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
included.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
included.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
// Process PotentialIndexes (Not Included in Current Policy)
if (indexMetrics.PotentialIndexes) {
// Single indexes
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
notIncluded.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
notIncluded.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
return { included, notIncluded };
}
export const renderImpactDots = (impact: string): JSX.Element => {
const style = useIndexAdvisorStyles();
let count = 0;
if (impact === "High") {
count = 3;
} else if (impact === "Medium") {
count = 2;
} else if (impact === "Low") {
count = 1;
}
return (
<div className={style.indexAdvisorImpactDots}>
{Array.from({ length: count }).map((_, i) => (
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
))}
</div>
);
};

View File

@@ -3,21 +3,18 @@ import QueryError from "Common/QueryError";
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
import { MessageBanner } from "Explorer/Controls/MessageBanner";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import useZoomLevel from "hooks/useZoomLevel";
import React from "react";
import { conditionalClass } from "Utils/StyleUtils";
import RunQuery from "../../../../images/RunQuery.png";
import { QueryResults } from "../../../Contracts/ViewModels";
import { ErrorList } from "./ErrorList";
import { ResultsView } from "./ResultsView";
import useZoomLevel from "hooks/useZoomLevel";
import { conditionalClass } from "Utils/StyleUtils";
export interface ResultsViewProps {
isMongoDB: boolean;
queryResults: QueryResults;
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}
interface QueryResultProps extends ResultsViewProps {
@@ -52,8 +49,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -96,9 +91,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

@@ -52,9 +52,8 @@ describe("QueryTabComponent", () => {
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",

View File

@@ -27,7 +27,6 @@ import { TabsState, useTabs } from "hooks/useTabs";
import React, { Fragment, createRef } from "react";
import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format";
import create from "zustand";
//TODO: Uncomment next two lines when query copilot is reinstated in DE
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
@@ -57,20 +56,6 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase";
import "./QueryTabComponent.less";
export interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));
enum ToggleState {
Result,
QueryMetrics,
@@ -275,10 +260,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}
public onExecuteQueryClick = async (): Promise<void> => {
const query1 = this.state.sqlQueryEditorContent;
const db = this.props.collection.databaseId;
const container = this.props.collection.id();
useQueryMetadataStore.getState().setMetadata(query1, db, container);
this._iterator = undefined;
setTimeout(async () => {
@@ -794,8 +775,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.props.copilotStore?.errors}
isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
@@ -811,8 +790,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.state.errors}
isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex)
}

View File

@@ -1,170 +0,0 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
import React from "react";
const mockReplace = jest.fn();
const mockFetchAll = jest.fn();
const mockRead = jest.fn();
const mockLogConsoleProgress = jest.fn();
const mockHandleError = jest.fn();
const indexMetricsResponse = {
UtilizedIndexes: {
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
},
PotentialIndexes: {
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
},
};
const mockQueryResults = {
documents: [] as unknown[],
hasMoreResults: false,
itemCount: 0,
firstItemIndex: 0,
lastItemIndex: 0,
requestCharge: 0,
activityId: "test-activity-id",
};
mockRead.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
excludedPaths: [],
},
partitionKey: "pk",
},
});
mockReplace.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }],
excludedPaths: [],
},
},
});
jest.mock("Common/CosmosClient", () => ({
client: () => ({
database: () => ({
container: () => ({
items: {
query: () => ({
fetchAll: mockFetchAll,
}),
},
read: mockRead,
replace: mockReplace,
}),
}),
}),
}));
jest.mock("./StylesAdvisor", () => ({
useIndexAdvisorStyles: () => ({}),
}));
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
logConsoleProgress: (...args: unknown[]) => {
mockLogConsoleProgress(...args);
return () => {};
},
}));
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
handleError: (...args: unknown[]) => mockHandleError(...args),
}));
beforeEach(() => {
jest.clearAllMocks();
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
});
describe("IndexAdvisorTab Basic Tests", () => {
test("component renders without crashing", () => {
const { container } = render(
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
);
expect(container).toBeTruthy();
});
test("renders component and handles missing parameters", () => {
const { container } = render(<IndexAdvisorTab />);
expect(container).toBeTruthy();
// Should not crash when parameters are missing
});
test("fetches index metrics with query results", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
});
test("displays content after loading", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
// Wait for the component to finish loading
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
// Component should have rendered some content
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
});
test("calls log console progress when fetching metrics", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
});
test("handles error when fetch fails", async () => {
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
});
test("renders with all required props", () => {
const { container } = render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="testDb"
containerId="testContainer"
/>,
);
expect(container).toBeTruthy();
expect(container.firstChild).toBeTruthy();
});
});

View File

@@ -1,8 +1,5 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -11,45 +8,28 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
import { HttpHeaders } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { QueryResults } from "Contracts/ViewModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import {
parseIndexMetrics,
renderImpactDots,
type IndexMetricsResponse,
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import React, { useCallback, useEffect, useState } from "react";
import { userContext } from "UserContext";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { ResultsViewProps } from "./QueryResultSection";
import { useIndexAdvisorStyles } from "./StylesAdvisor";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
IndexAdvisor = "indexadv",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
/* eslint-disable react/prop-types */
@@ -543,331 +523,14 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
export interface IIndexMetric {
index: string;
impact: string;
section: "Included" | "Not Included" | "Header";
path?: string;
composite?: { path: string; order: string }[];
}
export const IndexAdvisorTab: React.FC<{
queryResults?: QueryResults;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
const style = useIndexAdvisorStyles();
const [loading, setLoading] = useState(false);
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
const [showIncluded, setShowIncluded] = useState(true);
const [showNotIncluded, setShowNotIncluded] = useState(true);
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [updateMessageShown, setUpdateMessageShown] = useState(false);
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
const fetchIndexMetrics = async () => {
if (!queryEditorContent || !databaseId || !containerId) {
return;
}
setLoading(true);
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
try {
const querySpec = {
query: queryEditorContent,
};
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
const sdkResponse = await cosmosClient
.database(databaseId)
.container(containerId)
.items.query(querySpec, {
populateIndexMetrics: true,
})
.fetchAll();
const parsedMetrics =
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
setIndexMetrics(parsedMetrics);
} catch (error) {
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
} finally {
clearMessage();
setLoading(false);
}
};
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
useEffect(() => {
if (queryEditorContent && databaseId && containerId && queryResults) {
fetchIndexMetrics();
}
}, [queryResults]);
useEffect(() => {
if (!indexMetrics) {
return;
}
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
setIncludedIndexes(included);
setNotIncludedIndexes(notIncluded);
if (justUpdatedPolicy) {
setJustUpdatedPolicy(false);
} else {
setUpdateMessageShown(false);
}
}, [indexMetrics]);
useEffect(() => {
const allSelected =
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
setSelectAll(allSelected);
}, [selectedIndexes, notIncluded]);
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
if (checked) {
setSelectedIndexes((prev) => [...prev, indexObj]);
} else {
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
}
};
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setSelectedIndexes(checked ? notIncluded : []);
};
const handleUpdatePolicy = async () => {
setIsUpdating(true);
try {
const containerRef = client().database(databaseId).container(containerId);
const { resource: containerDef } = await containerRef.read();
const newIncludedPaths = selectedIndexes
.filter((index) => !index.composite)
.map((index) => {
return {
path: index.path,
};
});
const newCompositeIndexes: CompositePath[][] = selectedIndexes
.filter((index) => Array.isArray(index.composite))
.map(
(index) =>
(index.composite as { path: string; order: string }[]).map((comp) => ({
path: comp.path,
order: comp.order === "descending" ? "descending" : "ascending",
})) as CompositePath[],
);
const updatedPolicy: IndexingPolicy = {
...containerDef.indexingPolicy,
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
automatic: containerDef.indexingPolicy?.automatic ?? true,
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
};
await containerRef.replace({
id: containerId,
partitionKey: containerDef.partitionKey,
indexingPolicy: updatedPolicy,
});
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
const updatedNotIncluded: typeof notIncluded = [];
const newlyIncluded: typeof included = [];
for (const item of notIncluded) {
if (selectedIndexSet.has(item.index)) {
newlyIncluded.push(item);
} else {
updatedNotIncluded.push(item);
}
}
const newIncluded = [...included, ...newlyIncluded];
const newNotIncluded = updatedNotIncluded;
setIncludedIndexes(newIncluded);
setNotIncludedIndexes(newNotIncluded);
setSelectedIndexes([]);
setSelectAll(false);
setUpdateMessageShown(true);
setJustUpdatedPolicy(true);
} catch (err) {
console.error("Failed to update indexing policy:", err);
} finally {
setIsUpdating(false);
}
};
const renderRow = (item: IIndexMetric, index: number) => {
const isHeader = item.section === "Header";
const isNotIncluded = item.section === "Not Included";
return (
<TableRow key={index}>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
{isNotIncluded ? (
<Checkbox
checked={selectedIndexes.some((selected) => selected.index === item.index)}
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
/>
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
) : (
<div className={style.indexAdvisorCheckboxSpacer}></div>
)}
{isHeader ? (
<span
style={{ cursor: "pointer" }}
onClick={() => {
if (item.index === "Included in Current Policy") {
setShowIncluded(!showIncluded);
} else if (item.index === "Not Included in Current Policy") {
setShowNotIncluded(!showNotIncluded);
}
}}
>
{item.index === "Included in Current Policy" ? (
showIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)
) : showNotIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)}
</span>
) : (
<div className={style.indexAdvisorChevronSpacer}></div>
)}
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
{!isHeader && item.impact}
</div>
<div>{!isHeader && renderImpactDots(item.impact)}</div>
</div>
</TableCell>
</TableRow>
);
};
const indexMetricItems = React.useMemo(() => {
const items: IIndexMetric[] = [];
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
if (showNotIncluded) {
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
}
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
if (showIncluded) {
included.forEach((item) => items.push({ ...item, section: "Included" }));
}
return items;
}, [included, notIncluded, showIncluded, showNotIncluded]);
if (loading) {
return (
<div>
<Spinner
size="small"
style={
{
"--spinner-size": "16px",
"--spinner-thickness": "2px",
"--spinner-color": "#0078D4",
} as React.CSSProperties
}
/>
</div>
);
}
return (
<div>
<div className={style.indexAdvisorMessage}>
{updateMessageShown ? (
<>
<span className={style.indexAdvisorSuccessIcon}>
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
</span>
<span>
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
Settings.
</span>
</>
) : (
<>
<span>
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
Learn more about Indexing Metrics
</a>
.{" "}
</span>
</>
)}
</div>
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
<Table className={style.indexAdvisorTable}>
<TableHeader>
<TableRow>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
<div className={style.indexAdvisorCheckboxSpacer}></div>
<div className={style.indexAdvisorChevronSpacer}></div>
<div>Index</div>
<div>
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
</div>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
</Table>
{selectedIndexes.length > 0 && (
<div className={style.indexAdvisorButtonBar}>
{isUpdating ? (
<div className={style.indexAdvisorButtonSpinner}>
<Spinner size="tiny" />{" "}
</div>
) : (
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
Update Indexing Policy with selected index(es)
</button>
)}
</div>
)}
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({
isMongoDB,
queryResults,
executeQueryDocumentsPage,
queryEditorContent,
databaseId,
containerId,
}) => {
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
@@ -885,13 +548,6 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
>
Query Stats
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
id={ResultsTabs.IndexAdvisor}
value={ResultsTabs.IndexAdvisor}
>
Index Advisor
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
@@ -902,30 +558,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
{activeTab === ResultsTabs.IndexAdvisor && (
<IndexAdvisorTab
queryResults={queryResults}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
)}
</div>
</div>
);
};
export interface IndexingPolicyStore {
indexingPolicies: { [containerId: string]: IndexingPolicy };
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
}
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
indexingPolicies: {},
setIndexingPolicyFor: (containerId, indexingPolicy) =>
set((state) => ({
indexingPolicies: {
...state.indexingPolicies,
[containerId]: { ...indexingPolicy },
},
})),
}));

View File

@@ -1,95 +0,0 @@
import { makeStyles } from "@fluentui/react-components";
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
export const useIndexAdvisorStyles = makeStyles({
indexAdvisorMessage: {
padding: "1rem",
fontSize: "1.2rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
indexAdvisorSuccessIcon: {
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: "#107C10",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
indexAdvisorTitle: {
padding: "1rem",
fontSize: "1.3rem",
fontWeight: "bold",
},
indexAdvisorTable: {
display: "block",
alignItems: "center",
marginBottom: "7rem",
},
indexAdvisorGrid: {
display: "grid",
gridTemplateColumns: "30px 30px 1fr 50px 120px",
alignItems: "center",
gap: "15px",
fontWeight: "bold",
},
indexAdvisorCheckboxSpacer: {
width: "18px",
height: "18px",
},
indexAdvisorChevronSpacer: {
width: "24px",
},
indexAdvisorRowBold: {
fontWeight: "bold",
},
indexAdvisorRowNormal: {
fontWeight: "normal",
},
indexAdvisorRowImpactHeader: {
fontSize: 0,
},
indexAdvisorRowImpact: {
fontWeight: "normal",
},
indexAdvisorImpactDot: {
color: "#0078D4",
fontSize: "12px",
display: "inline-flex",
},
indexAdvisorImpactDots: {
display: "flex",
alignItems: "center",
gap: "4px",
},
indexAdvisorButtonBar: {
padding: "1rem",
marginTop: "-7rem",
flexWrap: "wrap",
},
indexAdvisorButtonSpinner: {
marginTop: "1rem",
minWidth: "320px",
minHeight: "40px",
display: "flex",
alignItems: "left",
justifyContent: "left",
marginLeft: "10rem",
},
indexAdvisorButton: {
backgroundColor: "#0078D4",
color: "white",
padding: "8px 16px",
border: "none",
borderRadius: "4px",
cursor: "pointer",
marginTop: "1rem",
fontSize: "1rem",
fontWeight: 500,
transition: "background 0.2s",
":hover": {
backgroundColor: "#005a9e",
},
},
});

View File

@@ -1,15 +0,0 @@
import create from "zustand";
interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));

View File

@@ -1,5 +1,6 @@
import { OpenTab } from "Contracts/ActionContracts";
import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts";
import { substringUtf } from "Utils/StringUtils";
import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import * as ThemeUtility from "../../Common/ThemeUtility";
@@ -154,13 +155,13 @@ export default class TabsBase extends WaitsForTemplateViewModel {
const db = this.database?.id();
if (coll) {
if (coll.length > 8) {
return coll.slice(0, 5) + "…" + options.title;
return substringUtf(coll, 0, 5) + "…" + options.title;
} else {
return coll + "." + options.title;
}
} else if (db) {
if (db.length > 8) {
return db.slice(0, 5) + "…" + options.title;
return substringUtf(db, 0, 5) + "…" + options.title;
} else {
return db + "." + options.title;
}

View File

@@ -87,7 +87,7 @@ const App: React.FunctionComponent = () => {
<KeyboardShortcutRoot>
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? (
<ContainerCopyPanel container={explorer} />
<ContainerCopyPanel explorer={explorer} />
) : (
<DivExplorer explorer={explorer} />
)}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0" />
<title>Notebook Viewer</title>
<link rel="shortcut icon" href="../../images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
</head>
<body>
<div class="notebookComponentContainer" id="notebookContent"></div>
</body>
</html>

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

@@ -25,7 +25,6 @@ export type Features = {
readonly notebookServerUrl?: string;
readonly sandboxNotebookOutputs: boolean;
readonly selfServeType?: string;
readonly pr?: string;
readonly showMinRUSurvey: boolean;
readonly ttl90Days: boolean;
readonly mongoProxyEndpoint?: string;
@@ -41,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;
@@ -96,7 +96,6 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
notebookServerUrl: get("notebookserverurl"),
sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"),
selfServeType: get("selfservetype"),
pr: get("pr"),
showMinRUSurvey: "true" === get("showminrusurvey"),
ttl90Days: "true" === get("ttl90days"),
autoscaleDefault: "true" === get("autoscaledefault"),
@@ -113,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

@@ -26,5 +26,22 @@ describe("StringUtils", () => {
const transformedString: string | undefined = StringUtils.stripSpacesFromString("");
expect(transformedString).toBe("");
});
it("should return the right number of characters regardless of bytes used per character", () => {
// Tried to use a sample of characters across the range for each of the individual byte lengths
const ascii = "!,n~!,n~!,n~";
const twoByteCharacters = "Āā߿܀Āā߿܀Āā߿܀";
const threeByteCharacters = "ࠀ倀ꀀࠀ倀ꀀࠀ倀ꀀ";
const fourByteCharacters = "𐀀𐔀𐨀𐿶𐀀𐔀𐨀𐿶𐀀𐔀𐨀𐿶";
// Used a random character generator for each of the different byte-lengths of characters for the mixed tests
const mixedByteSizes = "Yח䙶𫶾eԚ疿𱺿]߉ꗫ𢆤*ɉ貸𪡑";
expect(StringUtils.substringUtf(ascii, 0, 5)).toBe("!,n~!");
expect(StringUtils.substringUtf(twoByteCharacters, 0, 5)).toBe("Āā߿܀Ā");
expect(StringUtils.substringUtf(threeByteCharacters, 0, 5)).toBe("ࠀ倀ꀀࠀ");
expect(StringUtils.substringUtf(fourByteCharacters, 0, 5)).toBe("𐀀𐔀𐨀𐿶𐀀");
expect(StringUtils.substringUtf(mixedByteSizes, 0, 5)).toBe("Yח䙶𫶾e");
expect(StringUtils.substringUtf(mixedByteSizes, 4, 4)).toBe("eԚ疿𱺿");
});
});
});

View File

@@ -17,3 +17,58 @@ export function endsWith(stringToTest: string, suffix: string): boolean {
export function startsWith(stringToTest: string, prefix: string): boolean {
return stringToTest.indexOf(prefix) === 0;
}
/**
* Returns the input number of characters from a desired string but takes into account characters encoded with different byte sizes.
* @param text The text from which to return the subset
* @param startChar The starting character from @param text (zero-based)
* @param numChars The number of characters to return starting from @param startChar
* @returns The resulting slice of characters
*/
export const substringUtf = (text: string, startChar: number, numChars: number) => {
const encoded = new TextEncoder().encode(text);
let currentChar = 0;
let currentByte = 0;
let startByte = 0;
for (; currentChar < startChar + numChars; ) {
if (currentChar === startChar) {
startByte = currentByte;
}
/*
Unicode is utf encoded using 1, 2, 3, or 4 bytes
In a byte array, we know how many bytes the character is encoded based on the first byte because it
was developed such that the first byte's range never occurs in any other byte. Subsequent bytes are
always within 128 and 191. So in binary it breaks down like this:
1 byte: 0xxxxxxx
2 bytes: 110xxxxx 10xxxxxx
3 bytes: 1110xxxx 10xxxxxx 10xxxxxx
4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
*/
switch (true) {
// The originall ASCII set is between 0 (00000000) and 127 (01111111) and those only take up one byte
case encoded[currentByte] >= 0 && encoded[currentByte] <= 127:
currentByte++;
break;
// But if the first byte is within 192 (11000000) and 223 (11011111) then we know the character is two bytes:
case encoded[currentByte] >= 192 && encoded[currentByte] <= 223:
currentByte = currentByte + 2;
break;
// If the first byte is anything within 224 (11100000) and 239 (11101111) then the character is three bytes
case encoded[currentByte] >= 224 && encoded[currentByte] <= 239:
currentByte = currentByte + 3;
break;
// If the first byte is anything within 240 (11110000) and 247 (11110111) then the character is four bytes
case encoded[currentByte] >= 240 && encoded[currentByte] <= 247:
currentByte = currentByte + 4;
break;
// Anything past is an error for now
default:
throw new Error("Unrecognized character");
}
currentChar++;
}
return new TextDecoder().decode(encoded.slice(startByte, currentByte));
};

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) {

View File

@@ -6,6 +6,7 @@ export interface SidePanelState {
hasConsole: boolean;
panelContent?: JSX.Element;
headerText?: string;
setHeaderText: (headerText: string) => void;
openSidePanel: (headerText: string, panelContent: JSX.Element, panelWidth?: string, onClose?: () => void) => void;
closeSidePanel: () => void;
setPanelHasConsole: (hasConsole: boolean) => void;
@@ -15,6 +16,7 @@ export const useSidePanel: UseStore<SidePanelState> = create((set) => ({
isOpen: false,
panelWidth: "440px",
hasConsole: true,
setHeaderText: (headerText: string) => set((state) => ({ ...state, headerText })),
setPanelHasConsole: (hasConsole: boolean) => set((state) => ({ ...state, hasConsole })),
openSidePanel: (headerText, panelContent, panelWidth = "440px") =>
set((state) => ({ ...state, headerText, panelContent, panelWidth, isOpen: true })),

View File

@@ -28,7 +28,7 @@ async function main() {
const mongoDatabases = await client.mongoDBResources.listMongoDBDatabases(resourceGroupName, account.name);
for (const database of mongoDatabases) {
// Unfortunately Mongo does not provide a timestamp in ARM. There is no way to tell how old the DB is other thn encoding it in the ID :(
const timestamp = Number(database.name.split("-")[1]);
const timestamp = Number(database.name.split("_").pop());
if (timestamp && timestamp < thirtyMinutesAgo) {
await client.mongoDBResources.deleteMongoDBDatabase(resourceGroupName, account.name, database.name);
console.log(`DELETED: ${account.name} | ${database.name} | Age: ${friendlyTime(Date.now() - timestamp)}`);

View File

@@ -113,7 +113,6 @@ module.exports = function (_env = {}, argv = {}) {
hostedExplorer: "./src/HostedExplorer.tsx",
terminal: "./src/Terminal/index.ts",
cellOutputViewer: "./src/CellOutputViewer/CellOutputViewer.tsx",
notebookViewer: "./src/NotebookViewer/NotebookViewer.tsx",
galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx",
selfServe: "./src/SelfServe/SelfServe.tsx",
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
@@ -151,11 +150,6 @@ module.exports = function (_env = {}, argv = {}) {
template: "src/CellOutputViewer/cellOutputViewer.html",
chunks: ["cellOutputViewer"],
}),
new HtmlWebpackPlugin({
filename: "notebookViewer.html",
template: "src/NotebookViewer/notebookViewer.html",
chunks: ["notebookViewer"],
}),
new HtmlWebpackPlugin({
filename: "gallery.html",
template: "src/GalleryViewer/galleryViewer.html",