mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-01 15:22:08 +00:00
Compare commits
3 Commits
release/ig
...
users/aisa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba75c0594 | ||
|
|
c142e4ad8a | ||
|
|
fb95b44242 |
@@ -1,17 +0,0 @@
|
|||||||
<svg width="96" height="104" viewBox="0 0 96 104" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path opacity="0.2" d="M80.5008 81.2203L41.2637 58.2012L35.7705 61.9941L74.6152 84.6208L80.5008 81.2203Z" fill="#AAAAAA"/>
|
|
||||||
<path opacity="0.2" d="M60.2283 92.5992L20.9912 69.5801L15.498 73.373L54.3428 95.9997L60.2283 92.5992Z" fill="#AAAAAA"/>
|
|
||||||
<path d="M63.7596 30.9969L74.8768 37.4057L74.746 82.1359L35.7705 59.7708L35.9013 3.00781L63.7596 19.095V30.9969Z" fill="#C9C9C9"/>
|
|
||||||
<path d="M35.9014 3.00818L41.0022 0L68.8605 16.0872L63.7597 19.0954L35.9014 3.00818Z" fill="#AAAAAA"/>
|
|
||||||
<path d="M74.8769 37.4067L79.9777 34.5293L79.8469 79.2596L74.7461 82.2677L74.8769 37.4067Z" fill="#AAAAAA"/>
|
|
||||||
<path d="M43.4872 42.245L54.6043 48.6537L54.4735 93.384L15.498 71.0188L15.6288 14.2559L43.4872 30.3431V42.245Z" fill="#F4F4F4"/>
|
|
||||||
<path d="M15.6289 14.2562L20.7297 11.248L48.5881 27.3352L43.4872 30.3434L15.6289 14.2562Z" fill="#DCDCDC"/>
|
|
||||||
<path d="M54.6044 48.6547L59.7052 45.7773L59.5745 90.5076L54.4736 93.5158L54.6044 48.6547Z" fill="#DCDCDC"/>
|
|
||||||
<path d="M63.7598 19.0961L68.8606 16.0879L79.9778 34.5293L74.8769 37.4067L63.7598 19.0961Z" fill="#C9C9C9"/>
|
|
||||||
<path d="M63.7598 19.0957L74.8769 37.4063L63.7598 30.9976V19.0957Z" fill="#DCDCDC"/>
|
|
||||||
<path d="M43.4873 30.3441L48.5881 27.3359L59.7053 45.7774L54.6045 48.6548L43.4873 30.3441Z" fill="#F4F4F4"/>
|
|
||||||
<path d="M43.4873 30.3438L54.6045 48.6544L43.4873 42.2457V30.3438Z" fill="#C9C9C9"/>
|
|
||||||
<path d="M46.8751 52.4595V55.9693L23.2275 42.1367V38.627L46.8751 52.4595Z" fill="#C9C9C9"/>
|
|
||||||
<path d="M46.8751 59.0658V62.5756L23.2275 48.6914V45.1816L46.8751 59.0658Z" fill="#C9C9C9"/>
|
|
||||||
<path d="M46.8751 65.3621V68.8719L23.2275 54.9877V51.6328L46.8751 65.3621Z" fill="#C9C9C9"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -7,6 +7,7 @@ const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
|||||||
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
|
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
|
||||||
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
|
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
|
||||||
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
|
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 azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
||||||
|
|
||||||
const api = createProxyMiddleware({
|
const api = createProxyMiddleware({
|
||||||
@@ -56,7 +57,11 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
|
|||||||
|
|
||||||
fetch(`${githubApiUrl}/pulls/${pr}`)
|
fetch(`${githubApiUrl}/pulls/${pr}`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then(({ head: { sha } }) => {
|
.then(({ head: { ref, sha } }) => {
|
||||||
|
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
|
||||||
|
prUrl.hash = ref;
|
||||||
|
search.set("feature.pr", prUrl.href);
|
||||||
|
|
||||||
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
|
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
|
||||||
explorer.search = search.toString();
|
explorer.search = search.toString();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -90,10 +90,6 @@ export class CapabilityNames {
|
|||||||
public static readonly EnableServerless: string = "EnableServerless";
|
public static readonly EnableServerless: string = "EnableServerless";
|
||||||
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
|
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
|
||||||
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
|
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
|
||||||
public static readonly EnableDataMasking: string = "EnableDataMasking";
|
|
||||||
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
|
|
||||||
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
|
|
||||||
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CapacityMode {
|
export enum CapacityMode {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TagNames, WorkloadType } from "Common/Constants";
|
import { TagNames, WorkloadType } from "Common/Constants";
|
||||||
import { Tags } from "Contracts/DataModels";
|
import { Tags } from "Contracts/DataModels";
|
||||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||||
import { ApiType, userContext } from "../UserContext";
|
import { userContext } from "../UserContext";
|
||||||
|
|
||||||
function isVirtualNetworkFilterEnabled() {
|
function isVirtualNetworkFilterEnabled() {
|
||||||
return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled;
|
return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled;
|
||||||
@@ -33,33 +33,3 @@ export function isGlobalSecondaryIndexEnabled(): boolean {
|
|||||||
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
|
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDatabaseEndpoint = (apiType: ApiType): string => {
|
|
||||||
switch (apiType) {
|
|
||||||
case "Mongo":
|
|
||||||
return "mongodbDatabases";
|
|
||||||
case "Cassandra":
|
|
||||||
return "cassandraKeyspaces";
|
|
||||||
case "Gremlin":
|
|
||||||
return "gremlinDatabases";
|
|
||||||
case "Tables":
|
|
||||||
return "tables";
|
|
||||||
default:
|
|
||||||
case "SQL":
|
|
||||||
return "sqlDatabases";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCollectionEndpoint = (apiType: ApiType): string => {
|
|
||||||
switch (apiType) {
|
|
||||||
case "Mongo":
|
|
||||||
return "collections";
|
|
||||||
case "Cassandra":
|
|
||||||
return "tables";
|
|
||||||
case "Gremlin":
|
|
||||||
return "graphs";
|
|
||||||
default:
|
|
||||||
case "SQL":
|
|
||||||
return "containers";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getErrorMessage = (error: string | Error = ""): string => {
|
export const getErrorMessage = (error: string | Error = ""): string => {
|
||||||
let errorMessage = typeof error === "string" ? error : error.message;
|
const errorMessage = typeof error === "string" ? error : error.message;
|
||||||
if (!errorMessage) {
|
|
||||||
errorMessage = JSON.stringify(error);
|
|
||||||
}
|
|
||||||
return replaceKnownError(errorMessage);
|
return replaceKnownError(errorMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
.pager-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pager-container > div {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { IconButton, Text } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import "./Pager.css";
|
|
||||||
|
|
||||||
export interface PagerProps {
|
|
||||||
startIndex: number;
|
|
||||||
totalCount: number;
|
|
||||||
pageSize: number;
|
|
||||||
onLoadPage: (startIndex: number, pageSize: number) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
showFirstLast?: boolean;
|
|
||||||
showItemCount?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconButtonStyles = {
|
|
||||||
root: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
|
||||||
rootHovered: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
|
||||||
rootPressed: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
|
||||||
rootDisabled: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
|
||||||
rootFocused: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
outline: "none",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const Pager: React.FC<PagerProps> = ({
|
|
||||||
startIndex,
|
|
||||||
totalCount,
|
|
||||||
pageSize,
|
|
||||||
onLoadPage,
|
|
||||||
disabled = false,
|
|
||||||
showFirstLast = true,
|
|
||||||
showItemCount = true,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
// Calculate current page and total pages from startIndex
|
|
||||||
const currentPage = Math.floor(startIndex / pageSize) + 1;
|
|
||||||
const totalPages = Math.ceil(totalCount / pageSize);
|
|
||||||
const endIndex = Math.min(startIndex + pageSize, totalCount);
|
|
||||||
|
|
||||||
const handleFirstPage = () => onLoadPage(0, pageSize);
|
|
||||||
const handlePreviousPage = () => onLoadPage(startIndex - pageSize, pageSize);
|
|
||||||
const handleNextPage = () => onLoadPage(startIndex + pageSize, pageSize);
|
|
||||||
const handleLastPage = () => onLoadPage((totalPages - 1) * pageSize, pageSize);
|
|
||||||
|
|
||||||
if (totalCount === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className || "pager-container"}>
|
|
||||||
{showItemCount && (
|
|
||||||
<Text>
|
|
||||||
Showing {startIndex + 1} - {endIndex} of {totalCount} items
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
{showFirstLast && (
|
|
||||||
<IconButton
|
|
||||||
iconProps={{ iconName: "DoubleChevronLeft" }}
|
|
||||||
title="First page"
|
|
||||||
ariaLabel="Go to first page"
|
|
||||||
onClick={handleFirstPage}
|
|
||||||
disabled={disabled || currentPage === 1}
|
|
||||||
styles={iconButtonStyles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
iconProps={{ iconName: "ChevronLeft" }}
|
|
||||||
title="Previous page"
|
|
||||||
ariaLabel="Go to previous page"
|
|
||||||
onClick={handlePreviousPage}
|
|
||||||
disabled={disabled || currentPage === 1}
|
|
||||||
styles={iconButtonStyles}
|
|
||||||
/>
|
|
||||||
<Text>
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</Text>
|
|
||||||
<IconButton
|
|
||||||
iconProps={{ iconName: "ChevronRight" }}
|
|
||||||
title="Next page"
|
|
||||||
ariaLabel="Go to next page"
|
|
||||||
onClick={handleNextPage}
|
|
||||||
disabled={disabled || currentPage === totalPages}
|
|
||||||
styles={iconButtonStyles}
|
|
||||||
/>
|
|
||||||
{showFirstLast && (
|
|
||||||
<IconButton
|
|
||||||
iconProps={{ iconName: "DoubleChevronRight" }}
|
|
||||||
title="Last page"
|
|
||||||
ariaLabel="Go to last page"
|
|
||||||
onClick={handleLastPage}
|
|
||||||
disabled={disabled || currentPage === totalPages}
|
|
||||||
styles={iconButtonStyles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Pager;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Shimmer, ShimmerElementType, Stack } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
export interface IndentLevel {
|
|
||||||
level: number;
|
|
||||||
width?: string;
|
|
||||||
}
|
|
||||||
interface ShimmerTreeProps {
|
|
||||||
indentLevels: IndentLevel[];
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => {
|
|
||||||
const renderShimmers = (indent: IndentLevel) => (
|
|
||||||
<Shimmer
|
|
||||||
key={Math.random()}
|
|
||||||
shimmerElements={[
|
|
||||||
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` },
|
|
||||||
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
|
|
||||||
]}
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 8 }} style={{ width: "50%", ...style }} data-testid="shimmer-stack">
|
|
||||||
{indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShimmerTree;
|
|
||||||
@@ -10,42 +10,15 @@ export interface ArmEntity {
|
|||||||
resourceGroup?: string;
|
resourceGroup?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseAccountUserAssignedIdentity {
|
|
||||||
[key: string]: {
|
|
||||||
principalId: string;
|
|
||||||
clientId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DatabaseAccountIdentity {
|
|
||||||
type: string;
|
|
||||||
principalId?: string;
|
|
||||||
tenantId?: string;
|
|
||||||
userAssignedIdentities?: DatabaseAccountUserAssignedIdentity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DatabaseAccount extends ArmEntity {
|
export interface DatabaseAccount extends ArmEntity {
|
||||||
properties: DatabaseAccountExtendedProperties;
|
properties: DatabaseAccountExtendedProperties;
|
||||||
systemData?: DatabaseAccountSystemData;
|
systemData?: DatabaseAccountSystemData;
|
||||||
identity?: DatabaseAccountIdentity | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseAccountSystemData {
|
export interface DatabaseAccountSystemData {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseAccountBackupPolicy {
|
|
||||||
type: string;
|
|
||||||
/* periodicModeProperties?: {
|
|
||||||
backupIntervalInMinutes: number;
|
|
||||||
backupRetentionIntervalInHours: number;
|
|
||||||
backupStorageRedundancy: string;
|
|
||||||
};
|
|
||||||
continuousModeProperties?: {
|
|
||||||
tier: string;
|
|
||||||
}; */
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DatabaseAccountExtendedProperties {
|
export interface DatabaseAccountExtendedProperties {
|
||||||
documentEndpoint?: string;
|
documentEndpoint?: string;
|
||||||
disableLocalAuth?: boolean;
|
disableLocalAuth?: boolean;
|
||||||
@@ -56,8 +29,6 @@ export interface DatabaseAccountExtendedProperties {
|
|||||||
capabilities?: Capability[];
|
capabilities?: Capability[];
|
||||||
enableMultipleWriteLocations?: boolean;
|
enableMultipleWriteLocations?: boolean;
|
||||||
mongoEndpoint?: string;
|
mongoEndpoint?: string;
|
||||||
backupPolicy?: DatabaseAccountBackupPolicy;
|
|
||||||
defaultIdentity?: string;
|
|
||||||
readLocations?: DatabaseAccountResponseLocation[];
|
readLocations?: DatabaseAccountResponseLocation[];
|
||||||
writeLocations?: DatabaseAccountResponseLocation[];
|
writeLocations?: DatabaseAccountResponseLocation[];
|
||||||
enableFreeTier?: boolean;
|
enableFreeTier?: boolean;
|
||||||
@@ -130,24 +101,6 @@ export interface Subscription {
|
|||||||
authorizationSource?: string;
|
authorizationSource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseModel extends ArmEntity {
|
|
||||||
properties: DatabaseGetProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DatabaseGetProperties {
|
|
||||||
resource: DatabaseResource & ExtendedResourceProperties;
|
|
||||||
}
|
|
||||||
export interface DatabaseResource {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtendedResourceProperties {
|
|
||||||
readonly _rid?: string;
|
|
||||||
readonly _self?: string;
|
|
||||||
readonly _ts?: number;
|
|
||||||
readonly _etag?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionPolicies {
|
export interface SubscriptionPolicies {
|
||||||
locationPlacementId: string;
|
locationPlacementId: string;
|
||||||
quotaId: string;
|
quotaId: string;
|
||||||
@@ -210,7 +163,6 @@ export interface Collection extends Resource {
|
|||||||
geospatialConfig?: GeospatialConfig;
|
geospatialConfig?: GeospatialConfig;
|
||||||
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
|
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
|
||||||
fullTextPolicy?: FullTextPolicy;
|
fullTextPolicy?: FullTextPolicy;
|
||||||
dataMaskingPolicy?: DataMaskingPolicy;
|
|
||||||
schema?: ISchema;
|
schema?: ISchema;
|
||||||
requestSchema?: () => void;
|
requestSchema?: () => void;
|
||||||
computedProperties?: ComputedProperties;
|
computedProperties?: ComputedProperties;
|
||||||
@@ -275,17 +227,6 @@ export interface ComputedProperty {
|
|||||||
|
|
||||||
export type ComputedProperties = ComputedProperty[];
|
export type ComputedProperties = ComputedProperty[];
|
||||||
|
|
||||||
export interface DataMaskingPolicy {
|
|
||||||
includedPaths: Array<{
|
|
||||||
path: string;
|
|
||||||
strategy: string;
|
|
||||||
startPosition: number;
|
|
||||||
length: number;
|
|
||||||
}>;
|
|
||||||
excludedPaths: string[];
|
|
||||||
isPolicyEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MaterializedView {
|
export interface MaterializedView {
|
||||||
id: string;
|
id: string;
|
||||||
_rid: string;
|
_rid: string;
|
||||||
@@ -448,7 +389,7 @@ export interface VectorEmbeddingPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VectorEmbedding {
|
export interface VectorEmbedding {
|
||||||
dataType: "float32" | "uint8" | "int8";
|
dataType: "float32" | "float16" | "uint8" | "int8";
|
||||||
dimensions: number;
|
dimensions: number;
|
||||||
distanceFunction: "euclidean" | "cosine" | "dotproduct";
|
distanceFunction: "euclidean" | "cosine" | "dotproduct";
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -49,5 +49,4 @@ export enum MessageTypes {
|
|||||||
Ready, // unused. Can be removed if the portal uses the same list of enums.
|
Ready, // unused. Can be removed if the portal uses the same list of enums.
|
||||||
OpenCESCVAFeedbackBlade,
|
OpenCESCVAFeedbackBlade,
|
||||||
ActivateTab,
|
ActivateTab,
|
||||||
OpenContainerCopyFeedbackBlade,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ export interface Collection extends CollectionBase {
|
|||||||
requestSchema?: () => void;
|
requestSchema?: () => void;
|
||||||
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
|
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
|
||||||
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
|
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
|
||||||
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
|
|
||||||
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
|
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
|
||||||
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
|
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
|
||||||
usageSizeInKB: ko.Observable<number>;
|
usageSizeInKB: ko.Observable<number>;
|
||||||
@@ -445,7 +444,6 @@ export interface DataExplorerInputsFrame {
|
|||||||
};
|
};
|
||||||
feedbackPolicies?: any;
|
feedbackPolicies?: any;
|
||||||
aadToken?: string;
|
aadToken?: string;
|
||||||
containerCopyEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelfServeFrameInputs {
|
export interface SelfServeFrameInputs {
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import { logError } from "../../../Common/Logger";
|
|
||||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
|
||||||
import {
|
|
||||||
cancel,
|
|
||||||
complete,
|
|
||||||
create,
|
|
||||||
listByDatabaseAccount,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
|
||||||
import {
|
|
||||||
CreateJobRequest,
|
|
||||||
DataTransferJobGetResults,
|
|
||||||
} from "../../../Utils/arm/generatedClients/dataTransferService/types";
|
|
||||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
|
||||||
import {
|
|
||||||
convertTime,
|
|
||||||
convertToCamelCase,
|
|
||||||
COSMOS_SQL_COMPONENT,
|
|
||||||
extractErrorMessage,
|
|
||||||
formatUTCDateTime,
|
|
||||||
getAccountDetailsFromResourceId,
|
|
||||||
} from "../CopyJobUtils";
|
|
||||||
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
|
|
||||||
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
|
||||||
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
|
|
||||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
|
||||||
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
export const openCreateCopyJobPanel = () => {
|
|
||||||
const sidePanelState = useSidePanel.getState();
|
|
||||||
sidePanelState.setPanelHasConsole(false);
|
|
||||||
sidePanelState.openSidePanel(
|
|
||||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
|
||||||
<CreateCopyJobScreensProvider />,
|
|
||||||
"650px",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
|
|
||||||
const sidePanelState = useSidePanel.getState();
|
|
||||||
sidePanelState.setPanelHasConsole(false);
|
|
||||||
sidePanelState.openSidePanel(
|
|
||||||
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
|
|
||||||
<CopyJobDetails job={job} />,
|
|
||||||
"650px",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let copyJobsAbortController: AbortController | null = null;
|
|
||||||
|
|
||||||
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
|
||||||
try {
|
|
||||||
if (copyJobsAbortController) {
|
|
||||||
copyJobsAbortController.abort();
|
|
||||||
}
|
|
||||||
copyJobsAbortController = new AbortController();
|
|
||||||
|
|
||||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
|
||||||
userContext.databaseAccount?.id || "",
|
|
||||||
);
|
|
||||||
const response = await listByDatabaseAccount(
|
|
||||||
subscriptionId,
|
|
||||||
resourceGroup,
|
|
||||||
accountName,
|
|
||||||
copyJobsAbortController.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
const jobs = response.value || [];
|
|
||||||
if (!Array.isArray(jobs)) {
|
|
||||||
throw new Error("Invalid migration job status response: Expected an array of jobs.");
|
|
||||||
}
|
|
||||||
copyJobsAbortController = null;
|
|
||||||
|
|
||||||
/* added a lower bound to "0" and upper bound to "100" */
|
|
||||||
const calculateCompletionPercentage = (processed: number, total: number): number => {
|
|
||||||
if (
|
|
||||||
typeof processed !== "number" ||
|
|
||||||
typeof total !== "number" ||
|
|
||||||
!isFinite(processed) ||
|
|
||||||
!isFinite(total) ||
|
|
||||||
total <= 0
|
|
||||||
) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentage = Math.round((processed / total) * 100);
|
|
||||||
return Math.max(0, Math.min(100, percentage));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formattedJobs: CopyJobType[] = jobs
|
|
||||||
.filter(
|
|
||||||
(job: DataTransferJobGetResults) =>
|
|
||||||
job.properties?.source?.component === COSMOS_SQL_COMPONENT &&
|
|
||||||
job.properties?.destination?.component === COSMOS_SQL_COMPONENT,
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(current: DataTransferJobGetResults, next: DataTransferJobGetResults) =>
|
|
||||||
new Date(next.properties.lastUpdatedUtcTime).getTime() -
|
|
||||||
new Date(current.properties.lastUpdatedUtcTime).getTime(),
|
|
||||||
)
|
|
||||||
.map((job: DataTransferJobGetResults, index: number) => {
|
|
||||||
const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ID: (index + 1).toString(),
|
|
||||||
Mode: job.properties.mode,
|
|
||||||
Name: job.properties.jobName,
|
|
||||||
Source: job.properties.source,
|
|
||||||
Destination: job.properties.destination,
|
|
||||||
Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"],
|
|
||||||
CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount),
|
|
||||||
Duration: convertTime(job.properties.duration),
|
|
||||||
LastUpdatedTime: dateTimeObj.formattedDateTime,
|
|
||||||
timestamp: dateTimeObj.timestamp,
|
|
||||||
Error: job.properties.error ? extractErrorMessage(job.properties.error as unknown as CopyJobErrorType) : null,
|
|
||||||
} as CopyJobType;
|
|
||||||
});
|
|
||||||
return formattedJobs;
|
|
||||||
} catch (error) {
|
|
||||||
const errorContent = JSON.stringify(error.content || error.message || error);
|
|
||||||
if (errorContent.includes("signal is aborted without reason")) {
|
|
||||||
throw {
|
|
||||||
message:
|
|
||||||
"Please wait for the current fetch request to complete. The previous copy job fetch request was aborted.",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => {
|
|
||||||
try {
|
|
||||||
const { source, target, migrationType, jobName } = state;
|
|
||||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
|
||||||
userContext.databaseAccount?.id || "",
|
|
||||||
);
|
|
||||||
const body = {
|
|
||||||
properties: {
|
|
||||||
source: {
|
|
||||||
component: "CosmosDBSql",
|
|
||||||
remoteAccountName: source?.account?.name,
|
|
||||||
databaseName: source?.databaseId,
|
|
||||||
containerName: source?.containerId,
|
|
||||||
},
|
|
||||||
destination: {
|
|
||||||
component: "CosmosDBSql",
|
|
||||||
databaseName: target?.databaseId,
|
|
||||||
containerName: target?.containerId,
|
|
||||||
},
|
|
||||||
mode: migrationType,
|
|
||||||
},
|
|
||||||
} as unknown as CreateJobRequest;
|
|
||||||
|
|
||||||
const response = await create(subscriptionId, resourceGroup, accountName, jobName, body);
|
|
||||||
MonitorCopyJobsRefState.getState().ref?.refreshJobList();
|
|
||||||
onSuccess();
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
|
|
||||||
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobGetResults> => {
|
|
||||||
try {
|
|
||||||
let updateFn = null;
|
|
||||||
switch (action.toLowerCase()) {
|
|
||||||
case CopyJobActions.pause:
|
|
||||||
updateFn = pause;
|
|
||||||
break;
|
|
||||||
case CopyJobActions.resume:
|
|
||||||
updateFn = resume;
|
|
||||||
break;
|
|
||||||
case CopyJobActions.cancel:
|
|
||||||
updateFn = cancel;
|
|
||||||
break;
|
|
||||||
case CopyJobActions.complete:
|
|
||||||
updateFn = complete;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported action: ${action}`);
|
|
||||||
}
|
|
||||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
|
||||||
userContext.databaseAccount?.id || "",
|
|
||||||
);
|
|
||||||
const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error);
|
|
||||||
|
|
||||||
const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
|
|
||||||
const pattern = new RegExp(`'(${statusList.join("|")})'`, "g");
|
|
||||||
const normalizedErrorMessage = errorMessage.replace(
|
|
||||||
pattern,
|
|
||||||
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
|
|
||||||
);
|
|
||||||
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import { StyleConstants } from "../../../Common/StyleConstants";
|
|
||||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
|
||||||
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
|
|
||||||
import { ContainerCopyProps } from "../Types/CopyJobTypes";
|
|
||||||
import { getCommandBarButtons } from "./Utils";
|
|
||||||
|
|
||||||
const backgroundColor = StyleConstants.BaseLight;
|
|
||||||
const rootStyle = {
|
|
||||||
root: {
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
|
|
||||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
|
|
||||||
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="commandBarContainer">
|
|
||||||
<FluentCommandBar
|
|
||||||
ariaLabel="Use left and right arrow keys to navigate between commands"
|
|
||||||
styles={rootStyle}
|
|
||||||
items={controlButtons}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyJobCommandBar;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import AddIcon from "../../../../images/Add.svg";
|
|
||||||
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
|
|
||||||
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
|
|
||||||
import { configContext, Platform } from "../../../ConfigContext";
|
|
||||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import * as Actions from "../Actions/CopyJobActions";
|
|
||||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
|
||||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
|
||||||
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
|
|
||||||
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
|
|
||||||
const buttons: CopyJobCommandBarBtnType[] = [
|
|
||||||
{
|
|
||||||
key: "createCopyJob",
|
|
||||||
iconSrc: AddIcon,
|
|
||||||
label: ContainerCopyMessages.createCopyJobButtonLabel,
|
|
||||||
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
|
|
||||||
onClick: Actions.openCreateCopyJobPanel,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "refresh",
|
|
||||||
iconSrc: RefreshIcon,
|
|
||||||
label: ContainerCopyMessages.refreshButtonLabel,
|
|
||||||
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
|
|
||||||
onClick: () => monitorCopyJobsRef?.refreshJobList(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (configContext.platform === Platform.Portal) {
|
|
||||||
buttons.push({
|
|
||||||
key: "feedback",
|
|
||||||
iconSrc: FeedbackIcon,
|
|
||||||
label: ContainerCopyMessages.feedbackButtonLabel,
|
|
||||||
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
|
|
||||||
onClick: () => {
|
|
||||||
container.openContainerCopyFeedbackBlade();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps {
|
|
||||||
return {
|
|
||||||
iconSrc: config.iconSrc,
|
|
||||||
iconAlt: config.label,
|
|
||||||
onCommandClick: config.onClick,
|
|
||||||
commandButtonLabel: undefined as string | undefined,
|
|
||||||
ariaLabel: config.ariaLabel,
|
|
||||||
tooltipText: config.label,
|
|
||||||
hasPopup: false,
|
|
||||||
disabled: config.disabled ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
|
||||||
return getCopyJobBtns(container).map(btnMapper);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
export default {
|
|
||||||
// Copy Job Command Bar
|
|
||||||
feedbackButtonLabel: "Feedback",
|
|
||||||
feedbackButtonAriaLabel: "Provide feedback on copy jobs",
|
|
||||||
refreshButtonLabel: "Refresh",
|
|
||||||
refreshButtonAriaLabel: "Refresh copy jobs",
|
|
||||||
createCopyJobButtonLabel: "Create Copy Job",
|
|
||||||
createCopyJobButtonAriaLabel: "Create a new container copy job",
|
|
||||||
|
|
||||||
// No Copy Jobs Found
|
|
||||||
noCopyJobsTitle: "No copy jobs to show",
|
|
||||||
createCopyJobButtonText: "Create a container copy job",
|
|
||||||
|
|
||||||
// Copy Job Details
|
|
||||||
copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details",
|
|
||||||
errorTitle: "Error Details",
|
|
||||||
selectedContainers: "Selected Containers",
|
|
||||||
|
|
||||||
// Create Copy Job Panel
|
|
||||||
createCopyJobPanelTitle: "Create copy job",
|
|
||||||
|
|
||||||
// Select Account Screen
|
|
||||||
selectAccountDescription: "Please select a source account from which to copy.",
|
|
||||||
subscriptionDropdownLabel: "Subscription",
|
|
||||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
|
||||||
sourceAccountDropdownLabel: "Account",
|
|
||||||
sourceAccountDropdownPlaceholder: "Select an account",
|
|
||||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
|
||||||
|
|
||||||
// Select Source and Target Containers Screen
|
|
||||||
selectSourceAndTargetContainersDescription:
|
|
||||||
"Please select a source container and a destination container to copy to.",
|
|
||||||
sourceContainerSubHeading: "Source container",
|
|
||||||
targetContainerSubHeading: "Destination container",
|
|
||||||
databaseDropdownLabel: "Database",
|
|
||||||
databaseDropdownPlaceholder: "Select a database",
|
|
||||||
containerDropdownLabel: "Container",
|
|
||||||
containerDropdownPlaceholder: "Select a container",
|
|
||||||
|
|
||||||
// Preview and Create Screen
|
|
||||||
jobNameLabel: "Job name",
|
|
||||||
sourceSubscriptionLabel: "Source subscription",
|
|
||||||
sourceAccountLabel: "Source account",
|
|
||||||
sourceDatabaseLabel: "Source database",
|
|
||||||
sourceContainerLabel: "Source container",
|
|
||||||
targetDatabaseLabel: "Destination database",
|
|
||||||
targetContainerLabel: "Destination container",
|
|
||||||
|
|
||||||
// Assign Permissions Screen
|
|
||||||
assignPermissions: {
|
|
||||||
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.`,
|
|
||||||
},
|
|
||||||
toggleBtn: {
|
|
||||||
onText: "On",
|
|
||||||
offText: "Off",
|
|
||||||
},
|
|
||||||
addManagedIdentity: {
|
|
||||||
title: "System-assigned managed identity enabled.",
|
|
||||||
description:
|
|
||||||
"A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.",
|
|
||||||
descriptionHrefText: "Learn more about Managed identities.",
|
|
||||||
descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
|
||||||
toggleLabel: "System assigned managed identity",
|
|
||||||
tooltip: {
|
|
||||||
content: "Learn more about",
|
|
||||||
hrefText: "Managed Identities.",
|
|
||||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
|
||||||
},
|
|
||||||
userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.",
|
|
||||||
userAssignedIdentityLabel: "You may also select a user assigned managed identity.",
|
|
||||||
createUserAssignedIdentityLink: "Create User Assigned Managed Identity",
|
|
||||||
enablementTitle: "Enable system assigned managed identity",
|
|
||||||
enablementDescription: (accountName: string) =>
|
|
||||||
accountName
|
|
||||||
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. `
|
|
||||||
: "",
|
|
||||||
},
|
|
||||||
defaultManagedIdentity: {
|
|
||||||
title: "System-assigned managed identity set as default.",
|
|
||||||
description: (accountName: string) =>
|
|
||||||
`Set the system-assigned managed identity as default for "${accountName}" by switching it on.`,
|
|
||||||
tooltip: {
|
|
||||||
content: "Learn more about",
|
|
||||||
hrefText: "Default Managed Identities.",
|
|
||||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
|
||||||
},
|
|
||||||
popoverTitle: "System assigned managed identity set as default",
|
|
||||||
popoverDescription: (accountName: string) =>
|
|
||||||
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
|
|
||||||
},
|
|
||||||
readPermissionAssigned: {
|
|
||||||
title: "Read permissions assigned to the default identity.",
|
|
||||||
description:
|
|
||||||
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
|
|
||||||
tooltip: {
|
|
||||||
content: "Learn more about",
|
|
||||||
hrefText: "Read permissions.",
|
|
||||||
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
|
|
||||||
},
|
|
||||||
popoverTitle: "Read permissions assigned to default identity.",
|
|
||||||
popoverDescription:
|
|
||||||
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ",
|
|
||||||
},
|
|
||||||
pointInTimeRestore: {
|
|
||||||
title: "Point In Time Restore enabled",
|
|
||||||
description: (accessName: string) =>
|
|
||||||
`To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`,
|
|
||||||
tooltip: {
|
|
||||||
content: "Learn more about",
|
|
||||||
hrefText: "Continuous Backup",
|
|
||||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
|
|
||||||
},
|
|
||||||
buttonText: "Enable Point In Time Restore",
|
|
||||||
},
|
|
||||||
onlineCopyEnabled: {
|
|
||||||
title: "Online copy enabled",
|
|
||||||
description: (accountName: string) => `Enable Online copy on "${accountName}".`,
|
|
||||||
hrefText: "Learn more about online copy jobs",
|
|
||||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
|
|
||||||
buttonText: "Enable Online Copy",
|
|
||||||
},
|
|
||||||
MonitorJobs: {
|
|
||||||
Columns: {
|
|
||||||
lastUpdatedTime: "Date & time",
|
|
||||||
name: "Job name",
|
|
||||||
status: "Status",
|
|
||||||
completionPercentage: "Completion %",
|
|
||||||
duration: "Duration",
|
|
||||||
error: "Error message",
|
|
||||||
mode: "Mode",
|
|
||||||
actions: "Actions",
|
|
||||||
},
|
|
||||||
Actions: {
|
|
||||||
pause: "Pause",
|
|
||||||
resume: "Resume",
|
|
||||||
cancel: "Cancel",
|
|
||||||
complete: "Complete",
|
|
||||||
viewDetails: "View Details",
|
|
||||||
},
|
|
||||||
Status: {
|
|
||||||
Pending: "Pending",
|
|
||||||
InProgress: "In Progress",
|
|
||||||
Running: "In Progress",
|
|
||||||
Partitioning: "In Progress",
|
|
||||||
Paused: "Paused",
|
|
||||||
Completed: "Completed",
|
|
||||||
Failed: "Failed",
|
|
||||||
Faulted: "Failed",
|
|
||||||
Skipped: "Cancelled",
|
|
||||||
Cancelled: "Cancelled",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar";
|
|
||||||
import "./containerCopyStyles.less";
|
|
||||||
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
|
|
||||||
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
|
|
||||||
import { ContainerCopyProps } from "./Types/CopyJobTypes";
|
|
||||||
|
|
||||||
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
|
|
||||||
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
|
|
||||||
useEffect(() => {
|
|
||||||
if (monitorCopyJobsRef.current) {
|
|
||||||
MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current);
|
|
||||||
}
|
|
||||||
}, [monitorCopyJobsRef.current]);
|
|
||||||
return (
|
|
||||||
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
|
|
||||||
<CopyJobCommandBar container={container} />
|
|
||||||
<MonitorCopyJobs ref={monitorCopyJobsRef} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContainerCopyPanel;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
|
|
||||||
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
|
|
||||||
export const useCopyJobContext = (): CopyJobContextProviderType => {
|
|
||||||
const context = React.useContext(CopyJobContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useCopyJobContext must be used within a CopyJobContextProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CopyJobContextProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInitialCopyJobState = (): CopyJobContextState => {
|
|
||||||
return {
|
|
||||||
jobName: "",
|
|
||||||
migrationType: CopyJobMigrationType.Offline,
|
|
||||||
source: {
|
|
||||||
subscription: null,
|
|
||||||
account: null,
|
|
||||||
databaseId: "",
|
|
||||||
containerId: "",
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
subscriptionId: userContext.subscriptionId || "",
|
|
||||||
account: userContext.databaseAccount || null,
|
|
||||||
databaseId: "",
|
|
||||||
containerId: "",
|
|
||||||
},
|
|
||||||
sourceReadAccessFromTarget: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
|
|
||||||
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
|
|
||||||
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
|
|
||||||
const [contextError, setContextError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const resetCopyJobState = () => {
|
|
||||||
setCopyJobState(getInitialCopyJobState());
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue: CopyJobContextProviderType = {
|
|
||||||
contextError,
|
|
||||||
setContextError,
|
|
||||||
copyJobState,
|
|
||||||
setCopyJobState,
|
|
||||||
flow,
|
|
||||||
setFlow,
|
|
||||||
resetCopyJobState,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyJobContextProvider;
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { DatabaseAccount } from "Contracts/DataModels";
|
|
||||||
import { CopyJobErrorType } from "./Types/CopyJobTypes";
|
|
||||||
|
|
||||||
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
|
||||||
|
|
||||||
export const buildResourceLink = (resource: DatabaseAccount): string => {
|
|
||||||
const resourceId = resource.id;
|
|
||||||
let parentOrigin = window.location.ancestorOrigins?.[0] ?? window.location.origin;
|
|
||||||
|
|
||||||
if (/\/\/localhost:/.test(parentOrigin)) {
|
|
||||||
parentOrigin = azurePortalMpacEndpoint;
|
|
||||||
} else if (/\/\/cosmos\.azure/.test(parentOrigin)) {
|
|
||||||
parentOrigin = parentOrigin.replace("cosmos.azure", "portal.azure");
|
|
||||||
}
|
|
||||||
|
|
||||||
parentOrigin = parentOrigin.replace(/\/$/, "");
|
|
||||||
|
|
||||||
return `${parentOrigin}/#resource${resourceId}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const COSMOS_SQL_COMPONENT = "CosmosDBSql";
|
|
||||||
|
|
||||||
export const COPY_JOB_API_VERSION = "2025-05-01-preview";
|
|
||||||
|
|
||||||
export function buildDataTransferJobPath({
|
|
||||||
subscriptionId,
|
|
||||||
resourceGroup,
|
|
||||||
accountName,
|
|
||||||
jobName,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
subscriptionId: string;
|
|
||||||
resourceGroup: string;
|
|
||||||
accountName: string;
|
|
||||||
jobName?: string;
|
|
||||||
action?: string;
|
|
||||||
}) {
|
|
||||||
let path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`;
|
|
||||||
if (jobName) {
|
|
||||||
path += `/${jobName}`;
|
|
||||||
}
|
|
||||||
if (action) {
|
|
||||||
path += `/${action}`;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertTime(timeStr: string): string | null {
|
|
||||||
const timeParts = timeStr.split(":").map(Number);
|
|
||||||
|
|
||||||
if (timeParts.length !== 3 || timeParts.some(isNaN)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const formatPart = (value: number, unit: string) => {
|
|
||||||
if (unit === "seconds") {
|
|
||||||
value = Math.round(value);
|
|
||||||
}
|
|
||||||
return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const [hours, minutes, seconds] = timeParts;
|
|
||||||
const formattedTimeParts = [
|
|
||||||
formatPart(hours, "hours"),
|
|
||||||
formatPart(minutes, "minutes"),
|
|
||||||
formatPart(seconds, "seconds"),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return formattedTimeParts || "0 seconds";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
|
|
||||||
const date = new Date(utcStr);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
formattedDateTime: new Intl.DateTimeFormat("en-US", {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "medium",
|
|
||||||
timeZone: "UTC",
|
|
||||||
}).format(date),
|
|
||||||
timestamp: date.getTime(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertToCamelCase(str: string): string {
|
|
||||||
const formattedStr = str
|
|
||||||
.split(/\s+/)
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
||||||
.join("");
|
|
||||||
return formattedStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType {
|
|
||||||
return {
|
|
||||||
...error,
|
|
||||||
message: error.message.split("\r\n\r\n")[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccountDetailsFromResourceId(accountId: string | undefined) {
|
|
||||||
if (!accountId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const pattern = new RegExp(
|
|
||||||
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)",
|
|
||||||
"i",
|
|
||||||
);
|
|
||||||
const matches = accountId.match(pattern);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
|
|
||||||
return { subscriptionId, resourceGroup, accountName };
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import InfoTooltip from "../Components/InfoTooltip";
|
|
||||||
import PopoverMessage from "../Components/PopoverContainer";
|
|
||||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
|
||||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
|
||||||
import useToggle from "./hooks/useToggle";
|
|
||||||
|
|
||||||
const managedIdentityTooltip = (
|
|
||||||
<Text>
|
|
||||||
{ContainerCopyMessages.addManagedIdentity.tooltip.content}
|
|
||||||
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
|
||||||
|
|
||||||
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
|
||||||
const { copyJobState } = useCopyJobContext();
|
|
||||||
const [systemAssigned, onToggle] = useToggle(false);
|
|
||||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
|
||||||
<Text>
|
|
||||||
{ContainerCopyMessages.addManagedIdentity.description} 
|
|
||||||
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
|
|
||||||
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
|
|
||||||
</Link>{" "}
|
|
||||||
|
|
||||||
<InfoTooltip content={managedIdentityTooltip} />
|
|
||||||
</Text>
|
|
||||||
<Toggle
|
|
||||||
checked={systemAssigned}
|
|
||||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
|
||||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
|
||||||
onChange={onToggle}
|
|
||||||
/>
|
|
||||||
<PopoverMessage
|
|
||||||
isLoading={loading}
|
|
||||||
visible={systemAssigned}
|
|
||||||
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
|
|
||||||
onCancel={() => onToggle(null, false)}
|
|
||||||
onPrimary={handleAddSystemIdentity}
|
|
||||||
>
|
|
||||||
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
|
|
||||||
</PopoverMessage>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddManagedIdentity;
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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";
|
|
||||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
|
||||||
import InfoTooltip from "../Components/InfoTooltip";
|
|
||||||
import PopoverMessage from "../Components/PopoverContainer";
|
|
||||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
|
||||||
import useToggle from "./hooks/useToggle";
|
|
||||||
|
|
||||||
const TooltipContent = (
|
|
||||||
<Text>
|
|
||||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.content}
|
|
||||||
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
|
|
||||||
|
|
||||||
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
|
|
||||||
const [readPermissionAssigned, onToggle] = useToggle(false);
|
|
||||||
|
|
||||||
const handleAddReadPermission = useCallback(async () => {
|
|
||||||
const { source, target } = copyJobState;
|
|
||||||
const selectedSourceAccount = source?.account;
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
subscriptionId: sourceSubscriptionId,
|
|
||||||
resourceGroup: sourceResourceGroup,
|
|
||||||
accountName: sourceAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
const assignedRole = await assignRole(
|
|
||||||
sourceSubscriptionId,
|
|
||||||
sourceResourceGroup,
|
|
||||||
sourceAccountName,
|
|
||||||
target?.account?.identity?.principalId ?? "",
|
|
||||||
);
|
|
||||||
if (assignedRole) {
|
|
||||||
setCopyJobState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
sourceReadAccessFromTarget: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
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, setContextError]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
|
||||||
<Text className="toggle-label">
|
|
||||||
{ContainerCopyMessages.readPermissionAssigned.description} 
|
|
||||||
<InfoTooltip content={TooltipContent} />
|
|
||||||
</Text>
|
|
||||||
<Toggle
|
|
||||||
checked={readPermissionAssigned}
|
|
||||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
|
||||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
|
||||||
onChange={onToggle}
|
|
||||||
inlineLabel
|
|
||||||
styles={{
|
|
||||||
root: { marginTop: 8, marginBottom: 12 },
|
|
||||||
label: { display: "none" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PopoverMessage
|
|
||||||
isLoading={loading}
|
|
||||||
visible={readPermissionAssigned}
|
|
||||||
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
|
|
||||||
onCancel={() => onToggle(null, false)}
|
|
||||||
onPrimary={handleAddReadPermission}
|
|
||||||
>
|
|
||||||
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
|
|
||||||
</PopoverMessage>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddReadPermissionToDefaultIdentity;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Image, Stack, Text } from "@fluentui/react";
|
|
||||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
|
|
||||||
import WarningIcon from "../../../../../../images/warning.svg";
|
|
||||||
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import { isIntraAccountCopy } from "../../../CopyJobUtils";
|
|
||||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
|
||||||
import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
|
||||||
|
|
||||||
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
|
|
||||||
<AccordionItem key={id} value={id} disabled={disabled}>
|
|
||||||
<AccordionHeader className="accordionHeader">
|
|
||||||
<Text className="accordionHeaderText" variant="medium">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Image
|
|
||||||
className="statusIcon"
|
|
||||||
src={completed ? CheckmarkIcon : WarningIcon}
|
|
||||||
alt={completed ? "Checkmark icon" : "Warning icon"}
|
|
||||||
width={completed ? 20 : 24}
|
|
||||||
height={completed ? 20 : 24}
|
|
||||||
/>
|
|
||||||
</AccordionHeader>
|
|
||||||
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
|
|
||||||
<Component />
|
|
||||||
</AccordionPanel>
|
|
||||||
</AccordionItem>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AssignPermissions = () => {
|
|
||||||
const { copyJobState } = useCopyJobContext();
|
|
||||||
const permissionSections = usePermissionSections(copyJobState);
|
|
||||||
const [openItems, setOpenItems] = React.useState<string[]>([]);
|
|
||||||
|
|
||||||
const indentLevels = React.useMemo<IndentLevel[]>(
|
|
||||||
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const firstIncompleteSection = permissionSections.find((section) => !section.completed);
|
|
||||||
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
|
|
||||||
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
|
|
||||||
setOpenItems(nextOpenItems);
|
|
||||||
}
|
|
||||||
}, [permissionSections]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
|
|
||||||
<span>
|
|
||||||
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
|
|
||||||
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
|
|
||||||
copyJobState?.source?.account?.name || "",
|
|
||||||
)
|
|
||||||
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
|
|
||||||
</span>
|
|
||||||
{permissionSections?.length === 0 ? (
|
|
||||||
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
|
|
||||||
) : (
|
|
||||||
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
|
|
||||||
{permissionSections.map((section) => (
|
|
||||||
<PermissionSection key={section.id} {...section} />
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AssignPermissions;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import InfoTooltip from "../Components/InfoTooltip";
|
|
||||||
import PopoverMessage from "../Components/PopoverContainer";
|
|
||||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
|
||||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
|
||||||
import useToggle from "./hooks/useToggle";
|
|
||||||
|
|
||||||
const managedIdentityTooltip = (
|
|
||||||
<Text>
|
|
||||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content}
|
|
||||||
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
|
||||||
|
|
||||||
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
|
||||||
const { copyJobState } = useCopyJobContext();
|
|
||||||
const [defaultSystemAssigned, onToggle] = useToggle(false);
|
|
||||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
|
||||||
<div className="toggle-label">
|
|
||||||
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}
|
|
||||||
<InfoTooltip content={managedIdentityTooltip} />
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={defaultSystemAssigned}
|
|
||||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
|
||||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
|
||||||
onChange={onToggle}
|
|
||||||
inlineLabel
|
|
||||||
styles={{
|
|
||||||
root: { marginTop: 8, marginBottom: 12 },
|
|
||||||
label: { display: "none" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PopoverMessage
|
|
||||||
isLoading={loading}
|
|
||||||
visible={defaultSystemAssigned}
|
|
||||||
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
|
|
||||||
onCancel={() => onToggle(null, false)}
|
|
||||||
onPrimary={handleAddSystemIdentity}
|
|
||||||
>
|
|
||||||
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)}
|
|
||||||
</PopoverMessage>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DefaultManagedIdentity;
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Link, PrimaryButton, Stack } from "@fluentui/react";
|
|
||||||
import { CapabilityNames } from "Common/Constants";
|
|
||||||
import { DatabaseAccount } from "Contracts/DataModels";
|
|
||||||
import React from "react";
|
|
||||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
|
||||||
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";
|
|
||||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
|
|
||||||
const prevCapabilities = prev?.properties?.capabilities ?? [];
|
|
||||||
const nextCapabilities = next?.properties?.capabilities ?? [];
|
|
||||||
|
|
||||||
return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OnlineCopyEnabled: React.FC = () => {
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
|
|
||||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
|
||||||
const selectedSourceAccount = source?.account;
|
|
||||||
const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? [];
|
|
||||||
|
|
||||||
const {
|
|
||||||
subscriptionId: sourceSubscriptionId,
|
|
||||||
resourceGroup: sourceResourceGroup,
|
|
||||||
accountName: sourceAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
|
||||||
|
|
||||||
const handleFetchAccount = async () => {
|
|
||||||
try {
|
|
||||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
|
||||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
|
||||||
setCopyJobState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
source: { ...prevState.source, account: account },
|
|
||||||
}));
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
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 clearAccountFetchInterval = () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearIntervalAndShowRefresh = () => {
|
|
||||||
clearAccountFetchInterval();
|
|
||||||
setShowRefreshButton(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
setLoading(true);
|
|
||||||
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(() => {
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
|
||||||
<Stack.Item className="info-message">
|
|
||||||
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")} 
|
|
||||||
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
|
|
||||||
</Link>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{showRefreshButton ? (
|
|
||||||
<PrimaryButton
|
|
||||||
className="fullWidth"
|
|
||||||
text={ContainerCopyMessages.refreshButtonLabel}
|
|
||||||
iconProps={{ iconName: "Refresh" }}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PrimaryButton
|
|
||||||
className="fullWidth"
|
|
||||||
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
|
|
||||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={handleOnlineCopyEnable}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OnlineCopyEnabled;
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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 { logError } from "../../../../../Common/Logger";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
|
||||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
|
||||||
import InfoTooltip from "../Components/InfoTooltip";
|
|
||||||
|
|
||||||
const tooltipContent = (
|
|
||||||
<Text>
|
|
||||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.content}
|
|
||||||
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
|
|
||||||
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
|
|
||||||
const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? "";
|
|
||||||
const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? "";
|
|
||||||
|
|
||||||
return prevBackupPolicy !== nextBackupPolicy;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PointInTimeRestore: React.FC = () => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showRefreshButton, setShowRefreshButton] = useState(false);
|
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
|
||||||
const sourceAccountLink = buildResourceLink(source?.account);
|
|
||||||
const featureUrl = `${sourceAccountLink}/backupRestore`;
|
|
||||||
const selectedSourceAccount = source?.account;
|
|
||||||
const {
|
|
||||||
subscriptionId: sourceSubscriptionId,
|
|
||||||
resourceGroup: sourceResourceGroup,
|
|
||||||
accountName: sourceAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFetchAccount = async () => {
|
|
||||||
try {
|
|
||||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
|
||||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
|
||||||
setCopyJobState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
source: { ...prevState.source, account: account },
|
|
||||||
}));
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
|
|
||||||
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
|
|
||||||
clearAccountFetchInterval();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAccountFetchInterval = () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearIntervalAndShowRefresh = () => {
|
|
||||||
clearAccountFetchInterval();
|
|
||||||
setShowRefreshButton(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
setLoading(true);
|
|
||||||
handleFetchAccount();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openWindowAndMonitor = () => {
|
|
||||||
setLoading(true);
|
|
||||||
setShowRefreshButton(false);
|
|
||||||
window.open(featureUrl, "_blank");
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
handleFetchAccount();
|
|
||||||
}, 30 * 1000);
|
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(
|
|
||||||
() => {
|
|
||||||
clearIntervalAndShowRefresh();
|
|
||||||
},
|
|
||||||
10 * 60 * 1000,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
|
||||||
<Stack.Item className="toggle-label">
|
|
||||||
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
|
|
||||||
{tooltipContent && (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
<InfoTooltip content={tooltipContent} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{showRefreshButton ? (
|
|
||||||
<PrimaryButton
|
|
||||||
className="fullWidth"
|
|
||||||
text={ContainerCopyMessages.refreshButtonLabel}
|
|
||||||
iconProps={{ iconName: "Refresh" }}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PrimaryButton
|
|
||||||
className="fullWidth"
|
|
||||||
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
|
|
||||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={openWindowAndMonitor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PointInTimeRestore;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { DatabaseAccount } from "Contracts/DataModels";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { logError } from "../../../../../../Common/Logger";
|
|
||||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
|
||||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
|
||||||
|
|
||||||
interface UseManagedIdentityUpdaterParams {
|
|
||||||
updateIdentityFn: (
|
|
||||||
subscriptionId: string,
|
|
||||||
resourceGroup?: string,
|
|
||||||
accountName?: string,
|
|
||||||
) => Promise<DatabaseAccount | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseManagedIdentityUpdaterReturn {
|
|
||||||
loading: boolean;
|
|
||||||
handleAddSystemIdentity: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useManagedIdentity = (
|
|
||||||
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
|
|
||||||
): UseManagedIdentityUpdaterReturn => {
|
|
||||||
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const selectedTargetAccount = copyJobState?.target?.account;
|
|
||||||
const {
|
|
||||||
subscriptionId: targetSubscriptionId,
|
|
||||||
resourceGroup: targetResourceGroup,
|
|
||||||
accountName: targetAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
|
||||||
|
|
||||||
const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName);
|
|
||||||
if (updatedAccount) {
|
|
||||||
setCopyJobState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
target: { ...prevState.target, account: updatedAccount },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
|
|
||||||
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
|
|
||||||
setContextError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [copyJobState, updateIdentityFn, setCopyJobState]);
|
|
||||||
|
|
||||||
return { loading, handleAddSystemIdentity };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useManagedIdentity;
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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, isIntraAccountCopy } from "../../../../CopyJobUtils";
|
|
||||||
import {
|
|
||||||
BackupPolicyType,
|
|
||||||
CopyJobMigrationType,
|
|
||||||
DefaultIdentityType,
|
|
||||||
IdentityType,
|
|
||||||
} from "../../../../Enums/CopyJobEnums";
|
|
||||||
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
|
|
||||||
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
|
|
||||||
import AddManagedIdentity from "../AddManagedIdentity";
|
|
||||||
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
|
|
||||||
import DefaultManagedIdentity from "../DefaultManagedIdentity";
|
|
||||||
import OnlineCopyEnabled from "../OnlineCopyEnabled";
|
|
||||||
import PointInTimeRestore from "../PointInTimeRestore";
|
|
||||||
|
|
||||||
export interface PermissionSectionConfig {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
Component: React.ComponentType;
|
|
||||||
disabled: boolean;
|
|
||||||
completed?: boolean;
|
|
||||||
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SECTION_IDS = {
|
|
||||||
addManagedIdentity: "addManagedIdentity",
|
|
||||||
defaultManagedIdentity: "defaultManagedIdentity",
|
|
||||||
readPermissionAssigned: "readPermissionAssigned",
|
|
||||||
pointInTimeRestore: "pointInTimeRestore",
|
|
||||||
onlineCopyEnabled: "onlineCopyEnabled",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
|
|
||||||
{
|
|
||||||
id: SECTION_IDS.addManagedIdentity,
|
|
||||||
title: ContainerCopyMessages.addManagedIdentity.title,
|
|
||||||
Component: AddManagedIdentity,
|
|
||||||
disabled: true,
|
|
||||||
validate: (state: CopyJobContextState) => {
|
|
||||||
const targetAccountIdentityType = (state?.target?.account?.identity?.type ?? "").toLowerCase();
|
|
||||||
return (
|
|
||||||
targetAccountIdentityType === IdentityType.SystemAssigned ||
|
|
||||||
targetAccountIdentityType === IdentityType.UserAssigned
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SECTION_IDS.defaultManagedIdentity,
|
|
||||||
title: ContainerCopyMessages.defaultManagedIdentity.title,
|
|
||||||
Component: DefaultManagedIdentity,
|
|
||||||
disabled: true,
|
|
||||||
validate: (state: CopyJobContextState) => {
|
|
||||||
const targetAccountDefaultIdentity = (state?.target?.account?.properties?.defaultIdentity ?? "").toLowerCase();
|
|
||||||
return targetAccountDefaultIdentity === DefaultIdentityType.SystemAssignedIdentity;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SECTION_IDS.readPermissionAssigned,
|
|
||||||
title: ContainerCopyMessages.readPermissionAssigned.title,
|
|
||||||
Component: AddReadPermissionToDefaultIdentity,
|
|
||||||
disabled: true,
|
|
||||||
validate: async (state: CopyJobContextState) => {
|
|
||||||
const principalId = state?.target?.account?.identity?.principalId;
|
|
||||||
const selectedSourceAccount = state?.source?.account;
|
|
||||||
const {
|
|
||||||
subscriptionId: sourceSubscriptionId,
|
|
||||||
resourceGroup: sourceResourceGroup,
|
|
||||||
accountName: sourceAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
|
||||||
|
|
||||||
const rolesAssigned = await fetchRoleAssignments(
|
|
||||||
sourceSubscriptionId,
|
|
||||||
sourceResourceGroup,
|
|
||||||
sourceAccountName,
|
|
||||||
principalId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const roleDefinitions = await fetchRoleDefinitions(rolesAssigned ?? []);
|
|
||||||
return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
|
|
||||||
{
|
|
||||||
id: SECTION_IDS.pointInTimeRestore,
|
|
||||||
title: ContainerCopyMessages.pointInTimeRestore.title,
|
|
||||||
Component: PointInTimeRestore,
|
|
||||||
disabled: true,
|
|
||||||
validate: (state: CopyJobContextState) => {
|
|
||||||
const sourceAccountBackupPolicy = state?.source?.account?.properties?.backupPolicy?.type ?? "";
|
|
||||||
return sourceAccountBackupPolicy === BackupPolicyType.Continuous;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SECTION_IDS.onlineCopyEnabled,
|
|
||||||
title: ContainerCopyMessages.onlineCopyEnabled.title,
|
|
||||||
Component: OnlineCopyEnabled,
|
|
||||||
disabled: true,
|
|
||||||
validate: (state: CopyJobContextState) => {
|
|
||||||
const accountCapabilities = state?.source?.account?.properties?.capabilities ?? [];
|
|
||||||
const onlineCopyCapability = accountCapabilities.find(
|
|
||||||
(capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature,
|
|
||||||
);
|
|
||||||
return !!onlineCopyCapability;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the user has the Reader role based on role definitions.
|
|
||||||
*/
|
|
||||||
export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean {
|
|
||||||
return roleDefinitions?.some(
|
|
||||||
(role) =>
|
|
||||||
role.name === "00000000-0000-0000-0000-000000000001" ||
|
|
||||||
role.permissions.some(
|
|
||||||
(permission) =>
|
|
||||||
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") &&
|
|
||||||
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the permission sections configuration for the Assign Permissions screen.
|
|
||||||
* Memoizes derived values for performance and decouples logic for testability.
|
|
||||||
*/
|
|
||||||
const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => {
|
|
||||||
const sourceAccountId = state?.source?.account?.id || "";
|
|
||||||
const targetAccountId = state?.target?.account?.id || "";
|
|
||||||
|
|
||||||
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
|
|
||||||
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
|
|
||||||
const isValidatingRef = useRef(false);
|
|
||||||
|
|
||||||
const sectionToValidate = useMemo(() => {
|
|
||||||
const isSameAccount = isIntraAccountCopy(sourceAccountId, targetAccountId);
|
|
||||||
|
|
||||||
const baseSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
|
|
||||||
if (state.migrationType === CopyJobMigrationType.Online) {
|
|
||||||
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
|
|
||||||
}
|
|
||||||
return baseSections;
|
|
||||||
}, [sourceAccountId, targetAccountId, state.migrationType]);
|
|
||||||
|
|
||||||
const memoizedValidationCache = useMemo(() => {
|
|
||||||
if (state.migrationType === CopyJobMigrationType.Offline) {
|
|
||||||
validationCache.delete(SECTION_IDS.pointInTimeRestore);
|
|
||||||
validationCache.delete(SECTION_IDS.onlineCopyEnabled);
|
|
||||||
}
|
|
||||||
return validationCache;
|
|
||||||
}, [state.migrationType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const validateSections = async () => {
|
|
||||||
if (isValidatingRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isValidatingRef.current = true;
|
|
||||||
const result: PermissionSectionConfig[] = [];
|
|
||||||
const newValidationCache = new Map(memoizedValidationCache);
|
|
||||||
|
|
||||||
for (let i = 0; i < sectionToValidate.length; i++) {
|
|
||||||
const section = sectionToValidate[i];
|
|
||||||
|
|
||||||
if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) {
|
|
||||||
result.push({ ...section, completed: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (section.validate) {
|
|
||||||
const isValid = await section.validate(state);
|
|
||||||
newValidationCache.set(section.id, isValid);
|
|
||||||
result.push({ ...section, completed: isValid });
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
for (let j = i + 1; j < sectionToValidate.length; j++) {
|
|
||||||
result.push({ ...sectionToValidate[j], completed: false });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newValidationCache.set(section.id, false);
|
|
||||||
result.push({ ...section, completed: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationCache(newValidationCache);
|
|
||||||
setPermissionSections(result);
|
|
||||||
isValidatingRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
validateSections();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isValidatingRef.current = false;
|
|
||||||
};
|
|
||||||
}, [state, sectionToValidate]);
|
|
||||||
|
|
||||||
return permissionSections ?? [];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default usePermissionSections;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
const useToggle = (initialState = false) => {
|
|
||||||
const [state, setState] = useState<boolean>(initialState);
|
|
||||||
const onToggle = useCallback((_, checked?: boolean) => {
|
|
||||||
setState(checked);
|
|
||||||
}, []);
|
|
||||||
return [state, onToggle] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useToggle;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Stack } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface FieldRowProps {
|
|
||||||
label?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
labelClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FieldRow: React.FC<FieldRowProps> = ({ label = "", children, labelClassName = "" }) => {
|
|
||||||
return (
|
|
||||||
<Stack horizontal horizontalAlign="space-between" className="flex-row">
|
|
||||||
{label && (
|
|
||||||
<Stack.Item align="center" className="flex-fixed-width">
|
|
||||||
<label className={`field-label ${labelClassName}`}>{label}: </label>
|
|
||||||
</Stack.Item>
|
|
||||||
)}
|
|
||||||
<Stack.Item align="center" className="flex-grow-col">
|
|
||||||
{children}
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FieldRow;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import InfoIcon from "../../../../../../images/Info.svg";
|
|
||||||
|
|
||||||
const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => {
|
|
||||||
if (!content) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const hostStyles: Partial<ITooltipHostStyles> = { root: { display: "inline-block" } };
|
|
||||||
return (
|
|
||||||
<TooltipHost content={content} calloutProps={{ gapSpace: 0 }} styles={hostStyles}>
|
|
||||||
<Image src={InfoIcon} alt="Information" width={14} height={14} />
|
|
||||||
</TooltipHost>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default React.memo(InfoTooltip);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type NavigationControlsProps = {
|
|
||||||
primaryBtnText: string;
|
|
||||||
onPrimary: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isPrimaryDisabled: boolean;
|
|
||||||
isPreviousDisabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavigationControls: React.FC<NavigationControlsProps> = ({
|
|
||||||
primaryBtnText,
|
|
||||||
onPrimary,
|
|
||||||
onPrevious,
|
|
||||||
onCancel,
|
|
||||||
isPrimaryDisabled,
|
|
||||||
isPreviousDisabled,
|
|
||||||
}) => (
|
|
||||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
|
||||||
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
|
|
||||||
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
|
|
||||||
<DefaultButton text="Cancel" onClick={onCancel} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default React.memo(NavigationControls);
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/* eslint-disable react/prop-types */
|
|
||||||
/* eslint-disable react/display-name */
|
|
||||||
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface PopoverContainerProps {
|
|
||||||
isLoading?: boolean;
|
|
||||||
title?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onPrimary: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
|
|
||||||
({ isLoading = false, title, children, onPrimary, onCancel }) => {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
className={`popover-container foreground ${isLoading ? "loading" : ""}`}
|
|
||||||
tokens={{ childrenGap: 20 }}
|
|
||||||
style={{ maxWidth: 450 }}
|
|
||||||
>
|
|
||||||
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text>{children}</Text>
|
|
||||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
|
||||||
<PrimaryButton
|
|
||||||
text={isLoading ? "" : "Yes"}
|
|
||||||
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
|
||||||
onClick={onPrimary}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
interface PopoverMessageProps {
|
|
||||||
isLoading?: boolean;
|
|
||||||
visible: boolean;
|
|
||||||
title: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onPrimary: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PopoverMessage: React.FC<PopoverMessageProps> = ({
|
|
||||||
isLoading = false,
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
onCancel,
|
|
||||||
onPrimary,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
if (!visible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary} isLoading={isLoading}>
|
|
||||||
{children}
|
|
||||||
</PopoverContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PopoverMessage;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
const CreateCopyJobScreens: React.FC = () => {
|
|
||||||
const {
|
|
||||||
currentScreen,
|
|
||||||
isPrimaryDisabled,
|
|
||||||
isPreviousDisabled,
|
|
||||||
handlePrimary,
|
|
||||||
handlePrevious,
|
|
||||||
handleCancel,
|
|
||||||
primaryBtnText,
|
|
||||||
} = useCopyJobNavigation();
|
|
||||||
const { contextError, setContextError } = useCopyJobContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
|
|
||||||
<Stack.Item className="createCopyJobScreensContent">
|
|
||||||
{contextError && (
|
|
||||||
<MessageBar
|
|
||||||
className="createCopyJobErrorMessageBar"
|
|
||||||
messageBarType={MessageBarType.blocked}
|
|
||||||
isMultiline={false}
|
|
||||||
onDismiss={() => setContextError(null)}
|
|
||||||
dismissButtonAriaLabel="Close"
|
|
||||||
truncated={true}
|
|
||||||
overflowButtonAriaLabel="See more"
|
|
||||||
>
|
|
||||||
{contextError}
|
|
||||||
</MessageBar>
|
|
||||||
)}
|
|
||||||
{currentScreen?.component}
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item className="createCopyJobScreensFooter">
|
|
||||||
<NavigationControls
|
|
||||||
primaryBtnText={primaryBtnText}
|
|
||||||
onPrimary={handlePrimary}
|
|
||||||
onPrevious={handlePrevious}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
isPrimaryDisabled={isPrimaryDisabled}
|
|
||||||
isPreviousDisabled={isPreviousDisabled}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateCopyJobScreens;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import CopyJobContextProvider from "../../Context/CopyJobContext";
|
|
||||||
import CreateCopyJobScreens from "./CreateCopyJobScreens";
|
|
||||||
|
|
||||||
const CreateCopyJobScreensProvider = () => {
|
|
||||||
return (
|
|
||||||
<CopyJobContextProvider>
|
|
||||||
<CreateCopyJobScreens />
|
|
||||||
</CopyJobContextProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateCopyJobScreensProvider;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
|
|
||||||
import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
|
|
||||||
|
|
||||||
const PreviewCopyJob: React.FC = () => {
|
|
||||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
|
||||||
|
|
||||||
const selectedDatabaseAndContainers = [
|
|
||||||
{
|
|
||||||
sourceDatabaseName: copyJobState.source?.databaseId,
|
|
||||||
sourceContainerName: copyJobState.source?.containerId,
|
|
||||||
targetDatabaseName: copyJobState.target?.databaseId,
|
|
||||||
targetContainerName: copyJobState.target?.containerId,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const jobName = copyJobState.jobName;
|
|
||||||
|
|
||||||
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {
|
|
||||||
setCopyJobState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
jobName: newValue || "",
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer">
|
|
||||||
<FieldRow label={ContainerCopyMessages.jobNameLabel}>
|
|
||||||
<TextField value={jobName} onChange={onJobNameChange} />
|
|
||||||
</FieldRow>
|
|
||||||
<Stack>
|
|
||||||
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
|
|
||||||
<Text>{copyJobState.source?.subscription?.displayName}</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
|
||||||
<Text>{copyJobState.source?.account?.name}</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<DetailsList
|
|
||||||
items={selectedDatabaseAndContainers}
|
|
||||||
layoutMode={DetailsListLayoutMode.justified}
|
|
||||||
checkboxVisibility={2}
|
|
||||||
columns={getPreviewCopyJobDetailsListColumns()}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PreviewCopyJob;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { IColumn } from "@fluentui/react";
|
|
||||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
minWidth: 130,
|
|
||||||
maxWidth: 140,
|
|
||||||
styles: {
|
|
||||||
root: {
|
|
||||||
whiteSpace: "normal",
|
|
||||||
lineHeight: "1.2",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPreviewCopyJobDetailsListColumns = (): IColumn[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "sourcedbname",
|
|
||||||
name: ContainerCopyMessages.sourceDatabaseLabel,
|
|
||||||
fieldName: "sourceDatabaseName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sourcecolname",
|
|
||||||
name: ContainerCopyMessages.sourceContainerLabel,
|
|
||||||
fieldName: "sourceContainerName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "targetdbname",
|
|
||||||
name: ContainerCopyMessages.targetDatabaseLabel,
|
|
||||||
fieldName: "targetDatabaseName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "targetcolname",
|
|
||||||
name: ContainerCopyMessages.targetContainerLabel,
|
|
||||||
fieldName: "targetContainerName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/* eslint-disable react/prop-types */
|
|
||||||
/* eslint-disable react/display-name */
|
|
||||||
import { Dropdown } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
|
||||||
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
|
||||||
import FieldRow from "../../Components/FieldRow";
|
|
||||||
|
|
||||||
interface AccountDropdownProps {
|
|
||||||
options: DropdownOptionType[];
|
|
||||||
selectedKey?: string;
|
|
||||||
disabled: boolean;
|
|
||||||
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
|
|
||||||
({ options, selectedKey, disabled, onChange }) => (
|
|
||||||
<FieldRow label={ContainerCopyMessages.sourceAccountDropdownLabel}>
|
|
||||||
<Dropdown
|
|
||||||
placeholder={ContainerCopyMessages.sourceAccountDropdownPlaceholder}
|
|
||||||
ariaLabel={ContainerCopyMessages.sourceAccountDropdownLabel}
|
|
||||||
options={options}
|
|
||||||
disabled={disabled}
|
|
||||||
required
|
|
||||||
selectedKey={selectedKey}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/* eslint-disable react/prop-types */
|
|
||||||
/* eslint-disable react/display-name */
|
|
||||||
import { Checkbox, Stack } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
|
||||||
|
|
||||||
interface MigrationTypeCheckboxProps {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
|
|
||||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow">
|
|
||||||
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
|
|
||||||
</Stack>
|
|
||||||
));
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/* eslint-disable react/prop-types */
|
|
||||||
/* eslint-disable react/display-name */
|
|
||||||
import { Dropdown } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
|
||||||
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
|
||||||
import FieldRow from "../../Components/FieldRow";
|
|
||||||
|
|
||||||
interface SubscriptionDropdownProps {
|
|
||||||
options: DropdownOptionType[];
|
|
||||||
selectedKey?: string;
|
|
||||||
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.memo(
|
|
||||||
({ options, selectedKey, onChange }) => (
|
|
||||||
<FieldRow label={ContainerCopyMessages.subscriptionDropdownLabel}>
|
|
||||||
<Dropdown
|
|
||||||
placeholder={ContainerCopyMessages.subscriptionDropdownPlaceholder}
|
|
||||||
ariaLabel={ContainerCopyMessages.subscriptionDropdownLabel}
|
|
||||||
options={options}
|
|
||||||
required
|
|
||||||
selectedKey={selectedKey}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/* eslint-disable react/display-name */
|
|
||||||
import { Stack } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import { apiType } from "UserContext";
|
|
||||||
import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels";
|
|
||||||
import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts";
|
|
||||||
import { useSubscriptions } from "../../../../../hooks/useSubscriptions";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
|
||||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
|
||||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
|
||||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
|
||||||
import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils";
|
|
||||||
|
|
||||||
const SelectAccount = React.memo(() => {
|
|
||||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
|
||||||
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
|
|
||||||
|
|
||||||
const subscriptions: Subscription[] = useSubscriptions();
|
|
||||||
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
|
|
||||||
const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL");
|
|
||||||
|
|
||||||
const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
|
|
||||||
const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
|
|
||||||
|
|
||||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
|
||||||
<span>{ContainerCopyMessages.selectAccountDescription}</span>
|
|
||||||
|
|
||||||
<SubscriptionDropdown
|
|
||||||
options={subscriptionOptions}
|
|
||||||
selectedKey={selectedSubscriptionId}
|
|
||||||
onChange={(_ev, option) => handleSelectSourceAccount("subscription", option?.data)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AccountDropdown
|
|
||||||
options={accountOptions}
|
|
||||||
selectedKey={copyJobState?.source?.account?.id}
|
|
||||||
disabled={!selectedSubscriptionId}
|
|
||||||
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SelectAccount;
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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[],
|
|
||||||
accounts: DatabaseAccount[],
|
|
||||||
): {
|
|
||||||
subscriptionOptions: DropdownOptionType[];
|
|
||||||
accountOptions: DropdownOptionType[];
|
|
||||||
} {
|
|
||||||
const subscriptionOptions = React.useMemo(
|
|
||||||
() =>
|
|
||||||
subscriptions?.map((sub) => ({
|
|
||||||
key: sub.subscriptionId,
|
|
||||||
text: sub.displayName,
|
|
||||||
data: sub,
|
|
||||||
})) || [],
|
|
||||||
[subscriptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const accountOptions = React.useMemo(
|
|
||||||
() =>
|
|
||||||
accounts?.map((account) => ({
|
|
||||||
key: account.id,
|
|
||||||
text: account.name,
|
|
||||||
data: account,
|
|
||||||
})) || [],
|
|
||||||
[accounts],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { subscriptionOptions, accountOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
|
|
||||||
|
|
||||||
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
|
|
||||||
const { setValidationCache } = useCopyJobPrerequisitesCache();
|
|
||||||
const handleSelectSourceAccount = React.useCallback(
|
|
||||||
(type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
|
|
||||||
setCopyJobState((prevState: CopyJobContextState) => {
|
|
||||||
if (type === "subscription") {
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
source: {
|
|
||||||
...prevState.source,
|
|
||||||
subscription: data || null,
|
|
||||||
account: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (type === "account") {
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
source: {
|
|
||||||
...prevState.source,
|
|
||||||
account: data || null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return prevState;
|
|
||||||
});
|
|
||||||
setValidationCache(new Map<string, boolean>());
|
|
||||||
},
|
|
||||||
[setCopyJobState, setValidationCache],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMigrationTypeChange = React.useCallback(
|
|
||||||
(_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
|
||||||
setCopyJobState((prevState: CopyJobContextState) => ({
|
|
||||||
...prevState,
|
|
||||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
|
||||||
}));
|
|
||||||
setValidationCache(new Map<string, boolean>());
|
|
||||||
},
|
|
||||||
[setCopyJobState, setValidationCache],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { handleSelectSourceAccount, handleMigrationTypeChange };
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
export function dropDownChangeHandler(setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>) {
|
|
||||||
return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") =>
|
|
||||||
(_evnt: React.FormEvent, option: DropdownOptionType) => {
|
|
||||||
const value = option.key;
|
|
||||||
setCopyJobState((prevState) => {
|
|
||||||
switch (type) {
|
|
||||||
case "sourceDatabase":
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
source: { ...prevState.source, databaseId: value, containerId: undefined },
|
|
||||||
};
|
|
||||||
case "sourceContainer":
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
source: { ...prevState.source, containerId: value },
|
|
||||||
};
|
|
||||||
case "targetDatabase":
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
target: { ...prevState.target, databaseId: value, containerId: undefined },
|
|
||||||
};
|
|
||||||
case "targetContainer":
|
|
||||||
return {
|
|
||||||
...prevState,
|
|
||||||
target: { ...prevState.target, containerId: value },
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return prevState;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { Stack } from "@fluentui/react";
|
|
||||||
import { DatabaseModel } from "Contracts/DataModels";
|
|
||||||
import React from "react";
|
|
||||||
import { useDatabases } from "../../../../../hooks/useDatabases";
|
|
||||||
import { useDataContainers } from "../../../../../hooks/useDataContainers";
|
|
||||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
|
||||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
|
||||||
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
|
|
||||||
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
|
|
||||||
import { useMemoizedSourceAndTargetData } from "./memoizedData";
|
|
||||||
|
|
||||||
const SelectSourceAndTargetContainers = () => {
|
|
||||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
|
||||||
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
|
|
||||||
useMemoizedSourceAndTargetData(copyJobState);
|
|
||||||
|
|
||||||
const sourceDatabases = useDatabases(...sourceDbParams) || [];
|
|
||||||
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
|
|
||||||
const targetDatabases = useDatabases(...targetDbParams) || [];
|
|
||||||
const targetContainers = useDataContainers(...targetContainerParams) || [];
|
|
||||||
|
|
||||||
const sourceDatabaseOptions = React.useMemo(
|
|
||||||
() => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
|
||||||
[sourceDatabases],
|
|
||||||
);
|
|
||||||
const sourceContainerOptions = React.useMemo(
|
|
||||||
() => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
|
||||||
[sourceContainers],
|
|
||||||
);
|
|
||||||
const targetDatabaseOptions = React.useMemo(
|
|
||||||
() => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
|
||||||
[targetDatabases],
|
|
||||||
);
|
|
||||||
const targetContainerOptions = React.useMemo(
|
|
||||||
() => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
|
||||||
[targetContainers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
|
|
||||||
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
|
|
||||||
<DatabaseContainerSection
|
|
||||||
heading={ContainerCopyMessages.sourceContainerSubHeading}
|
|
||||||
databaseOptions={sourceDatabaseOptions}
|
|
||||||
selectedDatabase={source?.databaseId}
|
|
||||||
databaseDisabled={false}
|
|
||||||
databaseOnChange={onDropdownChange("sourceDatabase")}
|
|
||||||
containerOptions={sourceContainerOptions}
|
|
||||||
selectedContainer={source?.containerId}
|
|
||||||
containerDisabled={!source?.databaseId}
|
|
||||||
containerOnChange={onDropdownChange("sourceContainer")}
|
|
||||||
/>
|
|
||||||
<DatabaseContainerSection
|
|
||||||
heading={ContainerCopyMessages.targetContainerSubHeading}
|
|
||||||
databaseOptions={targetDatabaseOptions}
|
|
||||||
selectedDatabase={target?.databaseId}
|
|
||||||
databaseDisabled={false}
|
|
||||||
databaseOnChange={onDropdownChange("targetDatabase")}
|
|
||||||
containerOptions={targetContainerOptions}
|
|
||||||
selectedContainer={target?.containerId}
|
|
||||||
containerDisabled={!target?.databaseId}
|
|
||||||
containerOnChange={onDropdownChange("targetContainer")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SelectSourceAndTargetContainers;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Dropdown, Stack } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
|
||||||
import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes";
|
|
||||||
import FieldRow from "../../Components/FieldRow";
|
|
||||||
|
|
||||||
export const DatabaseContainerSection = ({
|
|
||||||
heading,
|
|
||||||
databaseOptions,
|
|
||||||
selectedDatabase,
|
|
||||||
databaseDisabled,
|
|
||||||
databaseOnChange,
|
|
||||||
containerOptions,
|
|
||||||
selectedContainer,
|
|
||||||
containerDisabled,
|
|
||||||
containerOnChange,
|
|
||||||
}: DatabaseContainerSectionProps) => (
|
|
||||||
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
|
|
||||||
<label className="subHeading">{heading}</label>
|
|
||||||
<FieldRow label={ContainerCopyMessages.databaseDropdownLabel}>
|
|
||||||
<Dropdown
|
|
||||||
placeholder={ContainerCopyMessages.databaseDropdownPlaceholder}
|
|
||||||
ariaLabel={ContainerCopyMessages.databaseDropdownLabel}
|
|
||||||
options={databaseOptions}
|
|
||||||
required
|
|
||||||
disabled={!!databaseDisabled}
|
|
||||||
selectedKey={selectedDatabase}
|
|
||||||
onChange={databaseOnChange}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
|
|
||||||
<Dropdown
|
|
||||||
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
|
|
||||||
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
|
|
||||||
options={containerOptions}
|
|
||||||
required
|
|
||||||
disabled={!!containerDisabled}
|
|
||||||
selectedKey={selectedContainer}
|
|
||||||
onChange={containerOnChange}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
|
||||||
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
|
|
||||||
const { source, target } = copyJobState ?? {};
|
|
||||||
const selectedSourceAccount = source?.account;
|
|
||||||
const selectedTargetAccount = target?.account;
|
|
||||||
const {
|
|
||||||
subscriptionId: sourceSubscriptionId,
|
|
||||||
resourceGroup: sourceResourceGroup,
|
|
||||||
accountName: sourceAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
|
||||||
const {
|
|
||||||
subscriptionId: targetSubscriptionId,
|
|
||||||
resourceGroup: targetResourceGroup,
|
|
||||||
accountName: targetAccountName,
|
|
||||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
|
||||||
|
|
||||||
const sourceDbParams = React.useMemo(
|
|
||||||
() => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams,
|
|
||||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceContainerParams = React.useMemo(
|
|
||||||
() =>
|
|
||||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams,
|
|
||||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetDbParams = React.useMemo(
|
|
||||||
() => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams,
|
|
||||||
[targetSubscriptionId, targetResourceGroup, targetAccountName],
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetContainerParams = React.useMemo(
|
|
||||||
() =>
|
|
||||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams,
|
|
||||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
|
||||||
import { useSidePanel } from "../../../../hooks/useSidePanel";
|
|
||||||
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
|
|
||||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
|
||||||
import { isIntraAccountCopy } from "../../CopyJobUtils";
|
|
||||||
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
|
|
||||||
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
|
|
||||||
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
|
|
||||||
|
|
||||||
type NavigationState = {
|
|
||||||
screenHistory: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Action = { type: "NEXT"; nextScreen: string } | { type: "PREVIOUS" } | { type: "RESET" };
|
|
||||||
|
|
||||||
function navigationReducer(state: NavigationState, action: Action): NavigationState {
|
|
||||||
switch (action.type) {
|
|
||||||
case "NEXT":
|
|
||||||
return {
|
|
||||||
screenHistory: [...state.screenHistory, action.nextScreen],
|
|
||||||
};
|
|
||||||
case "PREVIOUS":
|
|
||||||
return {
|
|
||||||
screenHistory: state.screenHistory.length > 1 ? state.screenHistory.slice(0, -1) : state.screenHistory,
|
|
||||||
};
|
|
||||||
case "RESET":
|
|
||||||
return {
|
|
||||||
screenHistory: [SCREEN_KEYS.SelectAccount],
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCopyJobNavigation() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
|
|
||||||
const screens = useCreateCopyJobScreensList();
|
|
||||||
const { validationCache: cache } = useCopyJobPrerequisitesCache();
|
|
||||||
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
|
|
||||||
|
|
||||||
const currentScreenKey = state.screenHistory[state.screenHistory.length - 1];
|
|
||||||
const currentScreen = screens.find((screen) => screen.key === currentScreenKey);
|
|
||||||
|
|
||||||
const isPrimaryDisabled = useMemo(() => {
|
|
||||||
if (isLoading) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState;
|
|
||||||
return !currentScreen?.validations.every((v) => v.validate(context));
|
|
||||||
}, [currentScreen.key, copyJobState, cache, isLoading]);
|
|
||||||
|
|
||||||
const primaryBtnText = useMemo(() => {
|
|
||||||
if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
|
||||||
return "Copy";
|
|
||||||
}
|
|
||||||
return "Next";
|
|
||||||
}, [currentScreenKey]);
|
|
||||||
|
|
||||||
const isPreviousDisabled = state.screenHistory.length <= 1;
|
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
dispatch({ type: "RESET" });
|
|
||||||
resetCopyJobState();
|
|
||||||
useSidePanel.getState().closeSidePanel();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getContainerIdentifiers = (container: typeof copyJobState.source | typeof copyJobState.target) => ({
|
|
||||||
accountId: container?.account?.id || "",
|
|
||||||
databaseId: container?.databaseId || "",
|
|
||||||
containerId: container?.containerId || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const areContainersIdentical = () => {
|
|
||||||
const { source, target } = copyJobState;
|
|
||||||
const sourceIds = getContainerIdentifiers(source);
|
|
||||||
const targetIds = getContainerIdentifiers(target);
|
|
||||||
|
|
||||||
return (
|
|
||||||
isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) &&
|
|
||||||
sourceIds.databaseId === targetIds.databaseId &&
|
|
||||||
sourceIds.containerId === targetIds.containerId
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldNotShowPermissionScreen = () => {
|
|
||||||
const { source, target, migrationType } = copyJobState;
|
|
||||||
const sourceIds = getContainerIdentifiers(source);
|
|
||||||
const targetIds = getContainerIdentifiers(target);
|
|
||||||
return (
|
|
||||||
migrationType === CopyJobMigrationType.Offline && isIntraAccountCopy(sourceIds.accountId, targetIds.accountId)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyJobSubmission = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await submitCreateCopyJob(copyJobState, handleCancel);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message || "Failed to create copy job. Please try again later."
|
|
||||||
: "Failed to create copy job. Please try again later.";
|
|
||||||
setContextError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrimary = useCallback(() => {
|
|
||||||
if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) {
|
|
||||||
setContextError(
|
|
||||||
"Source and destination containers cannot be the same. Please select different containers to proceed.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setContextError(null);
|
|
||||||
const transitions = {
|
|
||||||
[SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen()
|
|
||||||
? SCREEN_KEYS.SelectSourceAndTargetContainers
|
|
||||||
: SCREEN_KEYS.AssignPermissions,
|
|
||||||
[SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers,
|
|
||||||
[SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextScreen = transitions[currentScreenKey];
|
|
||||||
if (nextScreen) {
|
|
||||||
dispatch({ type: "NEXT", nextScreen });
|
|
||||||
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
|
||||||
handleCopyJobSubmission();
|
|
||||||
}
|
|
||||||
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
|
|
||||||
|
|
||||||
const handlePrevious = useCallback(() => {
|
|
||||||
dispatch({ type: "PREVIOUS" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentScreen,
|
|
||||||
isPrimaryDisabled,
|
|
||||||
isPreviousDisabled,
|
|
||||||
handlePrimary,
|
|
||||||
handlePrevious,
|
|
||||||
handleCancel,
|
|
||||||
primaryBtnText,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import create from "zustand";
|
|
||||||
|
|
||||||
interface CopyJobPrerequisitesCacheState {
|
|
||||||
validationCache: Map<string, boolean>;
|
|
||||||
setValidationCache: (cache: Map<string, boolean>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCopyJobPrerequisitesCache = create<CopyJobPrerequisitesCacheState>((set) => ({
|
|
||||||
validationCache: new Map<string, boolean>(),
|
|
||||||
setValidationCache: (cache) => set({ validationCache: cache }),
|
|
||||||
}));
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CopyJobContextState } from "../../Types/CopyJobTypes";
|
|
||||||
import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions";
|
|
||||||
import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob";
|
|
||||||
import SelectAccount from "../Screens/SelectAccount/SelectAccount";
|
|
||||||
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
|
|
||||||
|
|
||||||
const SCREEN_KEYS = {
|
|
||||||
SelectAccount: "SelectAccount",
|
|
||||||
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
|
|
||||||
PreviewCopyJob: "PreviewCopyJob",
|
|
||||||
AssignPermissions: "AssignPermissions",
|
|
||||||
};
|
|
||||||
|
|
||||||
type Validation = {
|
|
||||||
validate: (state: CopyJobContextState | Map<string, boolean>) => boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Screen = {
|
|
||||||
key: string;
|
|
||||||
component: React.ReactElement;
|
|
||||||
validations: Validation[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function useCreateCopyJobScreensList() {
|
|
||||||
return React.useMemo<Screen[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: SCREEN_KEYS.SelectAccount,
|
|
||||||
component: <SelectAccount />,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account,
|
|
||||||
message: "Please select a subscription and account to proceed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SCREEN_KEYS.SelectSourceAndTargetContainers,
|
|
||||||
component: <SelectSourceAndTargetContainers />,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
validate: (state: CopyJobContextState) =>
|
|
||||||
!!state?.source?.databaseId &&
|
|
||||||
!!state?.source?.containerId &&
|
|
||||||
!!state?.target?.databaseId &&
|
|
||||||
!!state?.target?.containerId,
|
|
||||||
message: "Please select source and target containers to proceed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SCREEN_KEYS.PreviewCopyJob,
|
|
||||||
component: <PreviewCopyJob />,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
validate: (state: CopyJobContextState) =>
|
|
||||||
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)),
|
|
||||||
message: "Please enter a job name to proceed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SCREEN_KEYS.AssignPermissions,
|
|
||||||
component: <AssignPermissions />,
|
|
||||||
validations: [
|
|
||||||
{
|
|
||||||
validate: (cache: Map<string, boolean>) => {
|
|
||||||
const cacheValuesIterator = Array.from(cache.values());
|
|
||||||
if (cacheValuesIterator.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allValid = cacheValuesIterator.every((isValid: boolean) => isValid);
|
|
||||||
return allValid;
|
|
||||||
},
|
|
||||||
message: "Please ensure all previous steps are valid to proceed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SCREEN_KEYS, useCreateCopyJobScreensList };
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export enum CopyJobMigrationType {
|
|
||||||
Offline = "offline",
|
|
||||||
Online = "online",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum IdentityType {
|
|
||||||
SystemAssigned = "systemassigned",
|
|
||||||
UserAssigned = "userassigned",
|
|
||||||
None = "none",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum DefaultIdentityType {
|
|
||||||
SystemAssignedIdentity = "systemassignedidentity",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum BackupPolicyType {
|
|
||||||
Continuous = "Continuous",
|
|
||||||
Periodic = "Periodic",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CopyJobStatusType {
|
|
||||||
Pending = "Pending",
|
|
||||||
InProgress = "InProgress",
|
|
||||||
Running = "Running",
|
|
||||||
Partitioning = "Partitioning",
|
|
||||||
Paused = "Paused",
|
|
||||||
Skipped = "Skipped",
|
|
||||||
Completed = "Completed",
|
|
||||||
Cancelled = "Cancelled",
|
|
||||||
Failed = "Failed",
|
|
||||||
Faulted = "Faulted",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CopyJobActions {
|
|
||||||
pause = "pause",
|
|
||||||
resume = "resume",
|
|
||||||
cancel = "cancel",
|
|
||||||
complete = "complete",
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
|
||||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
|
||||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
|
||||||
|
|
||||||
interface CopyJobActionMenuProps {
|
|
||||||
job: CopyJobType;
|
|
||||||
handleClick: HandleJobActionClickType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
|
||||||
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
CopyJobStatusType.Completed,
|
|
||||||
CopyJobStatusType.Cancelled,
|
|
||||||
CopyJobStatusType.Failed,
|
|
||||||
CopyJobStatusType.Faulted,
|
|
||||||
].includes(job.Status)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMenuItems = (): IContextualMenuProps["items"] => {
|
|
||||||
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
|
|
||||||
const updatingAction = updatingJobAction?.action;
|
|
||||||
|
|
||||||
const baseItems = [
|
|
||||||
{
|
|
||||||
key: CopyJobActions.pause,
|
|
||||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
|
||||||
iconProps: { iconName: "Pause" },
|
|
||||||
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
|
|
||||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: CopyJobActions.cancel,
|
|
||||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
|
||||||
iconProps: { iconName: "Cancel" },
|
|
||||||
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
|
|
||||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: CopyJobActions.resume,
|
|
||||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
|
||||||
iconProps: { iconName: "Play" },
|
|
||||||
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
|
|
||||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (CopyJobStatusType.Paused === job.Status) {
|
|
||||||
return baseItems.filter((item) => item.key !== CopyJobActions.pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CopyJobStatusType.Pending === job.Status) {
|
|
||||||
return baseItems.filter((item) => item.key !== CopyJobActions.resume);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
[CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status)
|
|
||||||
) {
|
|
||||||
const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume);
|
|
||||||
if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) {
|
|
||||||
filteredItems.push({
|
|
||||||
key: CopyJobActions.complete,
|
|
||||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
|
||||||
iconProps: { iconName: "CheckMark" },
|
|
||||||
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
|
|
||||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return filteredItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([CopyJobStatusType.Skipped].includes(job.Status)) {
|
|
||||||
return baseItems.filter((item) => item.key === CopyJobActions.resume);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
role="button"
|
|
||||||
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
|
|
||||||
menuProps={{ items: getMenuItems() }}
|
|
||||||
menuIconProps={{ iconName: "" }}
|
|
||||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
|
||||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyJobActionMenu;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { IColumn } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
|
||||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
|
||||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
|
||||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
|
||||||
|
|
||||||
export const getColumns = (
|
|
||||||
handleSort: (columnKey: string) => void,
|
|
||||||
handleActionClick: HandleJobActionClickType,
|
|
||||||
sortedColumnKey: string | undefined,
|
|
||||||
isSortedDescending: boolean,
|
|
||||||
): IColumn[] => [
|
|
||||||
{
|
|
||||||
key: "LastUpdatedTime",
|
|
||||||
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
|
|
||||||
fieldName: "LastUpdatedTime",
|
|
||||||
minWidth: 140,
|
|
||||||
maxWidth: 300,
|
|
||||||
isResizable: true,
|
|
||||||
isSorted: sortedColumnKey === "timestamp",
|
|
||||||
isSortedDescending: isSortedDescending,
|
|
||||||
onColumnClick: () => handleSort("timestamp"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Name",
|
|
||||||
name: ContainerCopyMessages.MonitorJobs.Columns.name,
|
|
||||||
fieldName: "Name",
|
|
||||||
minWidth: 140,
|
|
||||||
maxWidth: 300,
|
|
||||||
isResizable: true,
|
|
||||||
isSorted: sortedColumnKey === "Name",
|
|
||||||
isSortedDescending: isSortedDescending,
|
|
||||||
onColumnClick: () => handleSort("Name"),
|
|
||||||
onRender: (job: CopyJobType) => <span className="jobNameLink">{job.Name}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Mode",
|
|
||||||
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
|
|
||||||
fieldName: "Mode",
|
|
||||||
minWidth: 90,
|
|
||||||
maxWidth: 200,
|
|
||||||
isResizable: true,
|
|
||||||
isSorted: sortedColumnKey === "Mode",
|
|
||||||
isSortedDescending: isSortedDescending,
|
|
||||||
onColumnClick: () => handleSort("Mode"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "CompletionPercentage",
|
|
||||||
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
|
|
||||||
fieldName: "CompletionPercentage",
|
|
||||||
minWidth: 110,
|
|
||||||
maxWidth: 200,
|
|
||||||
isResizable: true,
|
|
||||||
isSorted: sortedColumnKey === "CompletionPercentage",
|
|
||||||
isSortedDescending: isSortedDescending,
|
|
||||||
onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`,
|
|
||||||
onColumnClick: () => handleSort("CompletionPercentage"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "CopyJobStatus",
|
|
||||||
name: ContainerCopyMessages.MonitorJobs.Columns.status,
|
|
||||||
fieldName: "Status",
|
|
||||||
minWidth: 130,
|
|
||||||
maxWidth: 200,
|
|
||||||
isResizable: true,
|
|
||||||
isSorted: sortedColumnKey === "Status",
|
|
||||||
isSortedDescending: isSortedDescending,
|
|
||||||
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
|
|
||||||
onColumnClick: () => handleSort("Status"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Actions",
|
|
||||||
name: "",
|
|
||||||
minWidth: 80,
|
|
||||||
maxWidth: 200,
|
|
||||||
isResizable: true,
|
|
||||||
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
|
|
||||||
import React, { memo } from "react";
|
|
||||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
|
||||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
|
||||||
import { CopyJobType } from "../../Types/CopyJobTypes";
|
|
||||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
|
||||||
|
|
||||||
interface CopyJobDetailsProps {
|
|
||||||
job: CopyJobType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionCss = {
|
|
||||||
verticalAlign: { display: "flex", flexDirection: "column" } as React.CSSProperties,
|
|
||||||
headingText: { marginBottom: "10px" } as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
minWidth: 100,
|
|
||||||
maxWidth: 130,
|
|
||||||
styles: {
|
|
||||||
root: {
|
|
||||||
whiteSpace: "normal",
|
|
||||||
lineHeight: "1.2",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCopyJobDetailsListColumns = (): IColumn[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "sourcedbcol",
|
|
||||||
name: ContainerCopyMessages.sourceDatabaseLabel,
|
|
||||||
fieldName: "sourceDatabaseName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sourcecol",
|
|
||||||
name: ContainerCopyMessages.sourceContainerLabel,
|
|
||||||
fieldName: "sourceContainerName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "targetdbcol",
|
|
||||||
name: ContainerCopyMessages.targetDatabaseLabel,
|
|
||||||
fieldName: "targetDatabaseName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "targetcol",
|
|
||||||
name: ContainerCopyMessages.targetContainerLabel,
|
|
||||||
fieldName: "targetContainerName",
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "statuscol",
|
|
||||||
name: ContainerCopyMessages.MonitorJobs.Columns.status,
|
|
||||||
fieldName: "jobStatus",
|
|
||||||
onRender: ({ jobStatus }: { jobStatus: CopyJobStatusType }) => <CopyJobStatusWithIcon status={jobStatus} />,
|
|
||||||
...commonProps,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
|
|
||||||
const selectedContainers = [
|
|
||||||
{
|
|
||||||
sourceContainerName: job?.Source?.containerName || "N/A",
|
|
||||||
sourceDatabaseName: job?.Source?.databaseName || "N/A",
|
|
||||||
targetContainerName: job?.Destination?.containerName || "N/A",
|
|
||||||
targetDatabaseName: job?.Destination?.databaseName || "N/A",
|
|
||||||
jobStatus: job?.Status || "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="copyJobDetailsContainer" tokens={{ childrenGap: 15 }} data-testid="copy-job-details">
|
|
||||||
{job.Error ? (
|
|
||||||
<Stack.Item data-testid="error-stack" style={sectionCss.verticalAlign}>
|
|
||||||
<Text className="bold" style={sectionCss.headingText}>
|
|
||||||
{ContainerCopyMessages.errorTitle}
|
|
||||||
</Text>
|
|
||||||
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
{job.Error.message}
|
|
||||||
</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
) : null}
|
|
||||||
<Stack.Item data-testid="selectedcollection-stack">
|
|
||||||
<Stack tokens={{ childrenGap: 15 }}>
|
|
||||||
<Stack.Item style={sectionCss.verticalAlign}>
|
|
||||||
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
|
|
||||||
<Text>{job.LastUpdatedTime}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item style={sectionCss.verticalAlign}>
|
|
||||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
|
||||||
<Text>{job.Source?.remoteAccountName}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item style={sectionCss.verticalAlign}>
|
|
||||||
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
|
|
||||||
<Text>{job.Mode}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item style={sectionCss.verticalAlign}>
|
|
||||||
<DetailsList
|
|
||||||
items={selectedContainers}
|
|
||||||
layoutMode={DetailsListLayoutMode.justified}
|
|
||||||
checkboxVisibility={2}
|
|
||||||
columns={getCopyJobDetailsListColumns()}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(CopyJobDetails, (prevProps, nextProps) => {
|
|
||||||
return prevProps.job.ID === nextProps.job.ID && prevProps.job.Error === nextProps.job.Error;
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
|
|
||||||
import React from "react";
|
|
||||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
|
||||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
|
||||||
|
|
||||||
const theme = getTheme();
|
|
||||||
|
|
||||||
const iconClass = mergeStyles({
|
|
||||||
fontSize: "16px",
|
|
||||||
marginRight: "8px",
|
|
||||||
});
|
|
||||||
|
|
||||||
const classNames = mergeStyleSets({
|
|
||||||
[CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
|
||||||
[CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
|
|
||||||
[CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
|
|
||||||
[CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
|
|
||||||
[CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
|
|
||||||
[CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
|
||||||
[CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
|
||||||
[CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
|
|
||||||
[CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
|
|
||||||
[CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
|
|
||||||
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
|
|
||||||
[CopyJobStatusType.Pending]: "Clock",
|
|
||||||
[CopyJobStatusType.Paused]: "CirclePause",
|
|
||||||
[CopyJobStatusType.Skipped]: "StatusCircleBlock2",
|
|
||||||
[CopyJobStatusType.Cancelled]: "StatusErrorFull",
|
|
||||||
[CopyJobStatusType.Failed]: "StatusErrorFull",
|
|
||||||
[CopyJobStatusType.Faulted]: "StatusErrorFull",
|
|
||||||
[CopyJobStatusType.Completed]: "CompletedSolid",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
|
|
||||||
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
|
|
||||||
|
|
||||||
const isSpinnerStatus = [
|
|
||||||
CopyJobStatusType.Running,
|
|
||||||
CopyJobStatusType.InProgress,
|
|
||||||
CopyJobStatusType.Partitioning,
|
|
||||||
].includes(status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack horizontal verticalAlign="center">
|
|
||||||
{isSpinnerStatus ? (
|
|
||||||
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
|
|
||||||
) : (
|
|
||||||
<FontIcon
|
|
||||||
aria-label={status}
|
|
||||||
iconName={iconMap[status] || "UnknownSolid"}
|
|
||||||
className={classNames[status] || classNames.unknown}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text>{statusText}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyJobStatusWithIcon;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ActionButton, Image } from "@fluentui/react";
|
|
||||||
import React, { useCallback } from "react";
|
|
||||||
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
|
|
||||||
import * as Actions from "../../Actions/CopyJobActions";
|
|
||||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
|
||||||
|
|
||||||
interface CopyJobsNotFoundProps {}
|
|
||||||
|
|
||||||
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = () => {
|
|
||||||
const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []);
|
|
||||||
return (
|
|
||||||
<div className="notFoundContainer flexContainer centerContent">
|
|
||||||
<Image src={CopyJobIcon} alt={ContainerCopyMessages.noCopyJobsTitle} width={100} height={100} />
|
|
||||||
<h4 className="noCopyJobsMessage">{ContainerCopyMessages.noCopyJobsTitle}</h4>
|
|
||||||
<ActionButton allowDisabledFocus className="createCopyJobButton" onClick={handleCreateCopyJob}>
|
|
||||||
{ContainerCopyMessages.createCopyJobButtonText}
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyJobsNotFound;
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import {
|
|
||||||
ConstrainMode,
|
|
||||||
DetailsListLayoutMode,
|
|
||||||
DetailsRow,
|
|
||||||
IColumn,
|
|
||||||
ScrollablePane,
|
|
||||||
ScrollbarVisibility,
|
|
||||||
ShimmeredDetailsList,
|
|
||||||
Stack,
|
|
||||||
Sticky,
|
|
||||||
StickyPositionType,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import Pager from "../../../../Common/Pager";
|
|
||||||
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
|
|
||||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
|
||||||
import { getColumns } from "./CopyJobColumns";
|
|
||||||
|
|
||||||
interface CopyJobsListProps {
|
|
||||||
jobs: CopyJobType[];
|
|
||||||
handleActionClick: HandleJobActionClickType;
|
|
||||||
pageSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
|
|
||||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
|
||||||
const [startIndex, setStartIndex] = React.useState(0);
|
|
||||||
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
|
|
||||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
|
||||||
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSortedJobs(jobs);
|
|
||||||
setStartIndex(0);
|
|
||||||
}, [jobs]);
|
|
||||||
|
|
||||||
const handleSort = (columnKey: string) => {
|
|
||||||
const isDescending = sortedColumnKey === columnKey ? !isSortedDescending : false;
|
|
||||||
const sorted = [...sortedJobs].sort((current: any, next: any) => {
|
|
||||||
if (current[columnKey] < next[columnKey]) {
|
|
||||||
return isDescending ? 1 : -1;
|
|
||||||
}
|
|
||||||
if (current[columnKey] > next[columnKey]) {
|
|
||||||
return isDescending ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
setSortedJobs(sorted);
|
|
||||||
setSortedColumnKey(columnKey);
|
|
||||||
setIsSortedDescending(isDescending);
|
|
||||||
setStartIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: IColumn[] = React.useMemo(
|
|
||||||
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
|
|
||||||
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
|
|
||||||
);
|
|
||||||
|
|
||||||
const _handleRowClick = React.useCallback((job: CopyJobType) => {
|
|
||||||
openCopyJobDetailsPanel(job);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _onRenderRow = React.useCallback((props: any) => {
|
|
||||||
return (
|
|
||||||
<div onClick={_handleRowClick.bind(null, props.item)}>
|
|
||||||
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.container}>
|
|
||||||
<Stack verticalFill={true}>
|
|
||||||
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
|
|
||||||
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
|
|
||||||
<ShimmeredDetailsList
|
|
||||||
onRenderRow={_onRenderRow}
|
|
||||||
checkboxVisibility={2}
|
|
||||||
columns={columns}
|
|
||||||
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
|
|
||||||
enableShimmer={false}
|
|
||||||
constrainMode={ConstrainMode.unconstrained}
|
|
||||||
layoutMode={DetailsListLayoutMode.justified}
|
|
||||||
onRenderDetailsHeader={(props, defaultRender) => (
|
|
||||||
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
|
|
||||||
{defaultRender({ ...props })}
|
|
||||||
</Sticky>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ScrollablePane>
|
|
||||||
</Stack.Item>
|
|
||||||
{sortedJobs.length > pageSize && (
|
|
||||||
<Stack.Item>
|
|
||||||
<Pager
|
|
||||||
disabled={false}
|
|
||||||
startIndex={startIndex}
|
|
||||||
totalCount={sortedJobs.length}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onLoadPage={(startIdx /* pageSize */) => {
|
|
||||||
setStartIndex(startIdx);
|
|
||||||
}}
|
|
||||||
showFirstLast={true}
|
|
||||||
showItemCount={true}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyJobsList;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import create from "zustand";
|
|
||||||
import { MonitorCopyJobsRef } from "./MonitorCopyJobs";
|
|
||||||
|
|
||||||
type MonitorCopyJobsRefStateType = {
|
|
||||||
ref: MonitorCopyJobsRef;
|
|
||||||
setRef: (ref: MonitorCopyJobsRef) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MonitorCopyJobsRefState = create<MonitorCopyJobsRefStateType>((set) => ({
|
|
||||||
ref: null,
|
|
||||||
setRef: (ref) => set({ ref: ref }),
|
|
||||||
}));
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/* eslint-disable react/display-name */
|
|
||||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
|
||||||
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
|
|
||||||
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
|
|
||||||
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
|
|
||||||
import { convertToCamelCase } from "../CopyJobUtils";
|
|
||||||
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
|
|
||||||
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
|
|
||||||
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
|
|
||||||
import CopyJobsList from "./Components/CopyJobsList";
|
|
||||||
|
|
||||||
const FETCH_INTERVAL_MS = 30 * 1000;
|
|
||||||
|
|
||||||
interface MonitorCopyJobsProps {}
|
|
||||||
|
|
||||||
export interface MonitorCopyJobsRef {
|
|
||||||
refreshJobList: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
|
|
||||||
const isUpdatingRef = React.useRef(false);
|
|
||||||
const isFirstFetchRef = React.useRef(true);
|
|
||||||
|
|
||||||
const indentLevels = React.useMemo<IndentLevel[]>(() => Array(7).fill({ level: 0, width: "100%" }), []);
|
|
||||||
|
|
||||||
const fetchJobs = React.useCallback(async () => {
|
|
||||||
if (isUpdatingRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (isFirstFetchRef.current) {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await getCopyJobs();
|
|
||||||
setJobs((prevJobs) => {
|
|
||||||
const isSame = JSON.stringify(prevJobs) === JSON.stringify(response);
|
|
||||||
return isSame ? prevJobs : response;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setError(error.message || "Failed to load copy jobs. Please try again later.");
|
|
||||||
} finally {
|
|
||||||
if (isFirstFetchRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
isFirstFetchRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchJobs();
|
|
||||||
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [fetchJobs]);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
refreshJobList: () => {
|
|
||||||
if (isUpdatingRef.current) {
|
|
||||||
setError("Please wait for the current update to complete before refreshing.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetchJobs();
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleActionClick = React.useCallback(
|
|
||||||
async (job: CopyJobType, action: string, setUpdatingJobAction: JobActionUpdatorType) => {
|
|
||||||
try {
|
|
||||||
isUpdatingRef.current = true;
|
|
||||||
setUpdatingJobAction({ jobName: job.Name, action });
|
|
||||||
const updatedCopyJob = await updateCopyJobStatus(job, action);
|
|
||||||
if (updatedCopyJob) {
|
|
||||||
setJobs((prevJobs) =>
|
|
||||||
prevJobs.map((prevJob) =>
|
|
||||||
prevJob.Name === updatedCopyJob.properties.jobName
|
|
||||||
? {
|
|
||||||
...prevJob,
|
|
||||||
Status: convertToCamelCase(updatedCopyJob.properties.status) as CopyJobStatusType,
|
|
||||||
}
|
|
||||||
: prevJob,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError(error.message || "Failed to update copy job status. Please try again later.");
|
|
||||||
} finally {
|
|
||||||
isUpdatingRef.current = false;
|
|
||||||
setUpdatingJobAction(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const memoizedJobsList = React.useMemo(() => {
|
|
||||||
if (loading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (jobs.length > 0) {
|
|
||||||
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
|
|
||||||
}
|
|
||||||
return <CopyJobsNotFound />;
|
|
||||||
}, [jobs, loading, handleActionClick]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack className="monitorCopyJobs flexContainer">
|
|
||||||
{loading && <ShimmerTree indentLevels={indentLevels} style={{ width: "100%", padding: "1rem 2.5rem" }} />}
|
|
||||||
{error && (
|
|
||||||
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
|
|
||||||
{error}
|
|
||||||
</MessageBar>
|
|
||||||
)}
|
|
||||||
{memoizedJobsList}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default MonitorCopyJobs;
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { DatabaseAccount, Subscription } from "Contracts/DataModels";
|
|
||||||
import React from "react";
|
|
||||||
import { ApiType } from "UserContext";
|
|
||||||
import { CosmosSqlDataTransferDataSourceSink } from "../../../Utils/arm/generatedClients/dataTransferService/types";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
|
||||||
|
|
||||||
export interface ContainerCopyProps {
|
|
||||||
container: Explorer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CopyJobCommandBarBtnType = {
|
|
||||||
key: string;
|
|
||||||
iconSrc: string;
|
|
||||||
label: string;
|
|
||||||
ariaLabel: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CopyJobTabForwardRefHandle = {
|
|
||||||
validate: (state: CopyJobContextState) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DropdownOptionType = {
|
|
||||||
key: string;
|
|
||||||
text: string;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
data: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DatabaseParams = [string | undefined, string | undefined, string | undefined, ApiType];
|
|
||||||
export type DataContainerParams = [
|
|
||||||
string | undefined,
|
|
||||||
string | undefined,
|
|
||||||
string | undefined,
|
|
||||||
string | undefined,
|
|
||||||
ApiType,
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface DatabaseContainerSectionProps {
|
|
||||||
heading: string;
|
|
||||||
databaseOptions: DropdownOptionType[];
|
|
||||||
selectedDatabase: string;
|
|
||||||
databaseDisabled?: boolean;
|
|
||||||
databaseOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
|
||||||
containerOptions: DropdownOptionType[];
|
|
||||||
selectedContainer: string;
|
|
||||||
containerDisabled?: boolean;
|
|
||||||
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CopyJobContextState {
|
|
||||||
jobName: string;
|
|
||||||
migrationType: CopyJobMigrationType;
|
|
||||||
sourceReadAccessFromTarget?: boolean;
|
|
||||||
source: {
|
|
||||||
subscription: Subscription;
|
|
||||||
account: DatabaseAccount;
|
|
||||||
databaseId: string;
|
|
||||||
containerId: string;
|
|
||||||
};
|
|
||||||
target: {
|
|
||||||
subscriptionId: string;
|
|
||||||
account: DatabaseAccount;
|
|
||||||
databaseId: string;
|
|
||||||
containerId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CopyJobFlowType {
|
|
||||||
currentScreen: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CopyJobContextProviderType {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CopyJobType = {
|
|
||||||
ID: string;
|
|
||||||
Mode: string;
|
|
||||||
Name: string;
|
|
||||||
Status: CopyJobStatusType;
|
|
||||||
CompletionPercentage: number;
|
|
||||||
Duration: string;
|
|
||||||
LastUpdatedTime: string;
|
|
||||||
timestamp: number;
|
|
||||||
Error?: CopyJobErrorType;
|
|
||||||
Source: CosmosSqlDataTransferDataSourceSink;
|
|
||||||
Destination: CosmosSqlDataTransferDataSourceSink;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CopyJobErrorType {
|
|
||||||
message: string;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CopyJobError {
|
|
||||||
message: string;
|
|
||||||
navigateToStep?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataTransferJobType = {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
properties: {
|
|
||||||
jobName: string;
|
|
||||||
status: string;
|
|
||||||
lastUpdatedUtcTime: string;
|
|
||||||
processedCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
mode: string;
|
|
||||||
duration: string;
|
|
||||||
source: {
|
|
||||||
databaseName: string;
|
|
||||||
collectionName: string;
|
|
||||||
component: string;
|
|
||||||
};
|
|
||||||
destination: {
|
|
||||||
databaseName: string;
|
|
||||||
collectionName: string;
|
|
||||||
component: string;
|
|
||||||
};
|
|
||||||
error: {
|
|
||||||
message: string;
|
|
||||||
code: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JobActionUpdatorType = React.Dispatch<React.SetStateAction<{ jobName: string; action: string } | null>>;
|
|
||||||
|
|
||||||
export type HandleJobActionClickType = (
|
|
||||||
job: CopyJobType,
|
|
||||||
action: string,
|
|
||||||
setUpdatingJobAction: JobActionUpdatorType,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export type AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => boolean;
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
@import "../../../less/Common/Constants.less";
|
|
||||||
|
|
||||||
#containerCopyWrapper {
|
|
||||||
.centerContent {
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.notFoundContainer {
|
|
||||||
.noCopyJobsMessage {
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 auto;
|
|
||||||
color: @FocusColor;
|
|
||||||
}
|
|
||||||
button.createCopyJobButton {
|
|
||||||
color: @LinkColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.createCopyJobScreensContainer {
|
|
||||||
height: 100%;
|
|
||||||
padding: 1em 1.5em;
|
|
||||||
|
|
||||||
label {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.flex-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
label.field-label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.flex-fixed-width {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
.flex-grow-col {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.databaseContainerSection {
|
|
||||||
label.subHeading {
|
|
||||||
font: inherit;
|
|
||||||
padding: unset;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordionHeader {
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
.accordionHeaderText {
|
|
||||||
margin-left: 5px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.statusIcon {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.popover-container {
|
|
||||||
button[disabled] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foreground {
|
|
||||||
z-index: 10;
|
|
||||||
background-color: white;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
transform: translate(0%, -9%);
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
.createCopyJobErrorMessageBar {
|
|
||||||
margin-bottom: 2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitorCopyJobs {
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
.ms-DetailsList {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.ms-DetailsHeader {
|
|
||||||
.ms-DetailsHeader-cell {
|
|
||||||
padding: @DefaultSpace 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: @DefaultFontSize;
|
|
||||||
color: @BaseHigh;
|
|
||||||
background-color: @BaseLow;
|
|
||||||
border-bottom: @ButtonBorderWidth solid @BaseMedium;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: @BaseMediumLow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ms-DetailsRow {
|
|
||||||
border-bottom: @ButtonBorderWidth solid @BaseMedium;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: @BaseMediumLow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ms-DetailsRow-cell {
|
|
||||||
padding: @MediumSpace 20px;
|
|
||||||
font-size: @DefaultFontSize;
|
|
||||||
color: @BaseHigh;
|
|
||||||
min-height: 48px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.jobNameLink {
|
|
||||||
color: @LinkColor;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button[role="button"] {
|
|
||||||
&.ms-Button--icon {
|
|
||||||
i.ms-Icon {
|
|
||||||
font-size: @LargeSpace;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.copyJobDetailsContainer {
|
|
||||||
padding: 1em 0 0 2em;
|
|
||||||
|
|
||||||
.ms-DetailsList {
|
|
||||||
width: 100%;
|
|
||||||
.ms-DetailsHeader-cellTitle, .ms-DetailsRow-cell {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.ms-DetailsRow-cell {
|
|
||||||
font-size: @DefaultFontSize;
|
|
||||||
color: @BaseHigh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bold {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullWidth {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
|
import { FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
|
||||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { isFullTextSearchPreviewFeaturesEnabled } from "Utils/CapabilityUtils";
|
|
||||||
|
|
||||||
export interface FullTextPoliciesComponentProps {
|
export interface FullTextPoliciesComponentProps {
|
||||||
fullTextPolicy: FullTextPolicy;
|
fullTextPolicy: FullTextPolicy;
|
||||||
@@ -234,29 +233,24 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getFullTextLanguageOptions = (englishOnly?: boolean): IDropdownOption[] => {
|
export const getFullTextLanguageOptions = (englishOnly?: boolean): IDropdownOption[] => {
|
||||||
const multiLanguageSupportEnabled: boolean = isFullTextSearchPreviewFeaturesEnabled() && !englishOnly;
|
|
||||||
const fullTextLanguageOptions: IDropdownOption[] = [
|
const fullTextLanguageOptions: IDropdownOption[] = [
|
||||||
{
|
{
|
||||||
key: "en-US",
|
key: "en-US",
|
||||||
text: "English (US)",
|
text: "English (US)",
|
||||||
},
|
},
|
||||||
...(multiLanguageSupportEnabled
|
{
|
||||||
? [
|
key: "fr-FR",
|
||||||
{
|
text: "French",
|
||||||
key: "fr-FR",
|
},
|
||||||
text: "French",
|
{
|
||||||
},
|
key: "de-DE",
|
||||||
{
|
text: "German",
|
||||||
key: "de-DE",
|
},
|
||||||
text: "German",
|
{
|
||||||
},
|
key: "es-ES",
|
||||||
{
|
text: "Spanish",
|
||||||
key: "es-ES",
|
},
|
||||||
text: "Spanish",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return fullTextLanguageOptions;
|
return englishOnly ? [fullTextLanguageOptions[0]] : fullTextLanguageOptions;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
|||||||
changeFeedPolicy: undefined,
|
changeFeedPolicy: undefined,
|
||||||
analyticalStorageTtl: undefined,
|
analyticalStorageTtl: undefined,
|
||||||
geospatialConfig: undefined,
|
geospatialConfig: undefined,
|
||||||
dataMaskingPolicy: {
|
|
||||||
includedPaths: [],
|
|
||||||
excludedPaths: ["/excludedPath"],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
},
|
|
||||||
indexes: [],
|
indexes: [],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -97,6 +92,7 @@ describe("SettingsComponent", () => {
|
|||||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||||
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
||||||
wrapper.setState({
|
wrapper.setState({
|
||||||
|
userCanChangeProvisioningTypes: true,
|
||||||
isAutoPilotSelected: true,
|
isAutoPilotSelected: true,
|
||||||
wasAutopilotOriginallySet: false,
|
wasAutopilotOriginallySet: false,
|
||||||
autoPilotThroughput: 1000,
|
autoPilotThroughput: 1000,
|
||||||
@@ -290,157 +286,4 @@ describe("SettingsComponent", () => {
|
|||||||
|
|
||||||
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
|
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle data masking policy updates correctly", async () => {
|
|
||||||
updateUserContext({
|
|
||||||
apiType: "SQL",
|
|
||||||
authType: AuthType.AAD,
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
|
||||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
|
||||||
|
|
||||||
wrapper.setState({
|
|
||||||
dataMaskingContent: {
|
|
||||||
includedPaths: [],
|
|
||||||
excludedPaths: ["/excludedPath"],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
},
|
|
||||||
dataMaskingContentBaseline: {
|
|
||||||
includedPaths: [],
|
|
||||||
excludedPaths: [],
|
|
||||||
isPolicyEnabled: false,
|
|
||||||
},
|
|
||||||
isDataMaskingDirty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await settingsComponentInstance.onSaveClick();
|
|
||||||
|
|
||||||
// The test needs to match what onDataMaskingContentChange returns
|
|
||||||
expect(updateCollection).toHaveBeenCalled();
|
|
||||||
|
|
||||||
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
|
|
||||||
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
|
|
||||||
includedPaths: [],
|
|
||||||
excludedPaths: ["/excludedPath"],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate data masking policy content", () => {
|
|
||||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
|
||||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
|
||||||
|
|
||||||
// Test with invalid data structure
|
|
||||||
// Use invalid data type for testing validation
|
|
||||||
type InvalidPolicy = Omit<DataModels.DataMaskingPolicy, "includedPaths"> & { includedPaths: string };
|
|
||||||
const invalidPolicy: InvalidPolicy = {
|
|
||||||
includedPaths: "invalid",
|
|
||||||
excludedPaths: [],
|
|
||||||
isPolicyEnabled: false,
|
|
||||||
};
|
|
||||||
// Use type assertion since we're deliberately testing with invalid data
|
|
||||||
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
|
|
||||||
|
|
||||||
// State should update with the content but also set validation errors
|
|
||||||
expect(wrapper.state("dataMaskingContent")).toEqual({
|
|
||||||
includedPaths: "invalid",
|
|
||||||
excludedPaths: [],
|
|
||||||
isPolicyEnabled: false,
|
|
||||||
});
|
|
||||||
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
|
|
||||||
|
|
||||||
// Test with valid data
|
|
||||||
const validPolicy = {
|
|
||||||
includedPaths: [
|
|
||||||
{
|
|
||||||
path: "/path1",
|
|
||||||
strategy: "mask",
|
|
||||||
startPosition: 0,
|
|
||||||
length: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
excludedPaths: ["/excludedPath"],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
|
|
||||||
|
|
||||||
// State should update with valid data and no validation errors
|
|
||||||
expect(wrapper.state("dataMaskingContent")).toEqual(validPolicy);
|
|
||||||
expect(wrapper.state("dataMaskingValidationErrors")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle data masking discard correctly", () => {
|
|
||||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
|
||||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
|
||||||
|
|
||||||
const baselinePolicy = {
|
|
||||||
includedPaths: [
|
|
||||||
{
|
|
||||||
path: "/basePath",
|
|
||||||
strategy: "mask",
|
|
||||||
startPosition: 0,
|
|
||||||
length: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
excludedPaths: ["/excludedPath1"],
|
|
||||||
isPolicyEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const modifiedPolicy = {
|
|
||||||
includedPaths: [
|
|
||||||
{
|
|
||||||
path: "/newPath",
|
|
||||||
strategy: "mask",
|
|
||||||
startPosition: 1,
|
|
||||||
length: 5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
excludedPaths: ["/excludedPath2"],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set initial state
|
|
||||||
wrapper.setState({
|
|
||||||
dataMaskingContent: modifiedPolicy,
|
|
||||||
dataMaskingContentBaseline: baselinePolicy,
|
|
||||||
isDataMaskingDirty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Call revert
|
|
||||||
settingsComponentInstance.onRevertClick();
|
|
||||||
|
|
||||||
// Verify state is reset
|
|
||||||
expect(wrapper.state("dataMaskingContent")).toEqual(baselinePolicy);
|
|
||||||
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
|
|
||||||
expect(wrapper.state("shouldDiscardDataMasking")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable save button when data masking has validation errors", () => {
|
|
||||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
|
||||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
|
||||||
|
|
||||||
// Initially, save button should be disabled
|
|
||||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
|
|
||||||
|
|
||||||
// Make data masking dirty with valid data
|
|
||||||
wrapper.setState({
|
|
||||||
isDataMaskingDirty: true,
|
|
||||||
dataMaskingValidationErrors: [],
|
|
||||||
});
|
|
||||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
|
||||||
|
|
||||||
// Add validation errors - save should be disabled
|
|
||||||
wrapper.setState({
|
|
||||||
dataMaskingValidationErrors: ["includedPaths must be an array"],
|
|
||||||
});
|
|
||||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
|
|
||||||
|
|
||||||
// Clear validation errors - save should be enabled again
|
|
||||||
wrapper.setState({
|
|
||||||
dataMaskingValidationErrors: [],
|
|
||||||
});
|
|
||||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||||
import { useDatabases } from "Explorer/useDatabases";
|
import { useDatabases } from "Explorer/useDatabases";
|
||||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||||
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||||
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import DiscardIcon from "../../../../images/discard.svg";
|
import DiscardIcon from "../../../../images/discard.svg";
|
||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
ConflictResolutionComponent,
|
ConflictResolutionComponent,
|
||||||
ConflictResolutionComponentProps,
|
ConflictResolutionComponentProps,
|
||||||
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
||||||
import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent";
|
|
||||||
import {
|
import {
|
||||||
GlobalSecondaryIndexComponent,
|
GlobalSecondaryIndexComponent,
|
||||||
GlobalSecondaryIndexComponentProps,
|
GlobalSecondaryIndexComponentProps,
|
||||||
@@ -152,12 +151,6 @@ export interface SettingsComponentState {
|
|||||||
conflictResolutionPolicyProcedureBaseline: string;
|
conflictResolutionPolicyProcedureBaseline: string;
|
||||||
isConflictResolutionDirty: boolean;
|
isConflictResolutionDirty: boolean;
|
||||||
|
|
||||||
dataMaskingContent: DataModels.DataMaskingPolicy;
|
|
||||||
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
|
||||||
shouldDiscardDataMasking: boolean;
|
|
||||||
isDataMaskingDirty: boolean;
|
|
||||||
dataMaskingValidationErrors: string[];
|
|
||||||
|
|
||||||
selectedTab: SettingsV2TabTypes;
|
selectedTab: SettingsV2TabTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,12 +258,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
shouldDiscardComputedProperties: false,
|
shouldDiscardComputedProperties: false,
|
||||||
isComputedPropertiesDirty: false,
|
isComputedPropertiesDirty: false,
|
||||||
|
|
||||||
dataMaskingContent: undefined,
|
|
||||||
dataMaskingContentBaseline: undefined,
|
|
||||||
shouldDiscardDataMasking: false,
|
|
||||||
isDataMaskingDirty: false,
|
|
||||||
dataMaskingValidationErrors: [],
|
|
||||||
|
|
||||||
conflictResolutionPolicyMode: undefined,
|
conflictResolutionPolicyMode: undefined,
|
||||||
conflictResolutionPolicyModeBaseline: undefined,
|
conflictResolutionPolicyModeBaseline: undefined,
|
||||||
conflictResolutionPolicyPath: undefined,
|
conflictResolutionPolicyPath: undefined,
|
||||||
@@ -347,7 +334,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
};
|
};
|
||||||
|
|
||||||
public isSaveSettingsButtonEnabled = (): boolean => {
|
public isSaveSettingsButtonEnabled = (): boolean => {
|
||||||
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
|
if (this.isOfferReplacePending()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +342,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.dataMaskingValidationErrors.length > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.state.isScaleSaveable ||
|
this.state.isScaleSaveable ||
|
||||||
this.state.isSubSettingsSaveable ||
|
this.state.isSubSettingsSaveable ||
|
||||||
@@ -366,16 +349,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
this.state.isIndexingPolicyDirty ||
|
this.state.isIndexingPolicyDirty ||
|
||||||
this.state.isConflictResolutionDirty ||
|
this.state.isConflictResolutionDirty ||
|
||||||
this.state.isComputedPropertiesDirty ||
|
this.state.isComputedPropertiesDirty ||
|
||||||
this.state.isDataMaskingDirty ||
|
|
||||||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
|
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
|
||||||
this.state.isThroughputBucketsSaveable
|
this.state.isThroughputBucketsSaveable
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
public isDiscardSettingsButtonEnabled = (): boolean => {
|
public isDiscardSettingsButtonEnabled = (): boolean => {
|
||||||
if (this.props.settingsTab.isExecuting()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
this.state.isScaleDiscardable ||
|
this.state.isScaleDiscardable ||
|
||||||
this.state.isSubSettingsDiscardable ||
|
this.state.isSubSettingsDiscardable ||
|
||||||
@@ -383,7 +362,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
this.state.isIndexingPolicyDirty ||
|
this.state.isIndexingPolicyDirty ||
|
||||||
this.state.isConflictResolutionDirty ||
|
this.state.isConflictResolutionDirty ||
|
||||||
this.state.isComputedPropertiesDirty ||
|
this.state.isComputedPropertiesDirty ||
|
||||||
this.state.isDataMaskingDirty ||
|
|
||||||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
|
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
|
||||||
this.state.isThroughputBucketsSaveable
|
this.state.isThroughputBucketsSaveable
|
||||||
);
|
);
|
||||||
@@ -439,6 +417,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
: this.saveDatabaseSettings(startKey));
|
: this.saveDatabaseSettings(startKey));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.props.settingsTab.isExecutionError(true);
|
this.props.settingsTab.isExecutionError(true);
|
||||||
|
console.error(error);
|
||||||
traceFailure(
|
traceFailure(
|
||||||
Action.SettingsV2Updated,
|
Action.SettingsV2Updated,
|
||||||
{
|
{
|
||||||
@@ -455,6 +434,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
} finally {
|
} finally {
|
||||||
this.props.settingsTab.isExecuting(false);
|
this.props.settingsTab.isExecuting(false);
|
||||||
|
|
||||||
|
// Send message to Fabric no matter success or failure.
|
||||||
|
// In case of failure, saveCollectionSettings might have partially succeeded and Fabric needs to refresh
|
||||||
if (isFabricNative() && this.isCollectionSettingsTab) {
|
if (isFabricNative() && this.isCollectionSettingsTab) {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: FabricMessageTypes.ContainerUpdated,
|
type: FabricMessageTypes.ContainerUpdated,
|
||||||
@@ -465,9 +446,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
};
|
};
|
||||||
|
|
||||||
public onRevertClick = (): void => {
|
public onRevertClick = (): void => {
|
||||||
if (this.props.settingsTab.isExecuting()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
|
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
|
||||||
message: "Settings Discarded",
|
message: "Settings Discarded",
|
||||||
});
|
});
|
||||||
@@ -508,10 +486,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
|
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
|
||||||
shouldDiscardComputedProperties: true,
|
shouldDiscardComputedProperties: true,
|
||||||
isComputedPropertiesDirty: false,
|
isComputedPropertiesDirty: false,
|
||||||
dataMaskingContent: this.state.dataMaskingContentBaseline,
|
|
||||||
shouldDiscardDataMasking: true,
|
|
||||||
isDataMaskingDirty: false,
|
|
||||||
dataMaskingValidationErrors: [],
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -674,36 +648,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
|
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
|
||||||
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
|
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
|
||||||
|
|
||||||
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
|
|
||||||
if (!newDataMasking.excludedPaths) {
|
|
||||||
newDataMasking.excludedPaths = [];
|
|
||||||
}
|
|
||||||
if (!newDataMasking.includedPaths) {
|
|
||||||
newDataMasking.includedPaths = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationErrors = [];
|
|
||||||
if (!Array.isArray(newDataMasking.includedPaths)) {
|
|
||||||
validationErrors.push("includedPaths must be an array");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(newDataMasking.excludedPaths)) {
|
|
||||||
validationErrors.push("excludedPaths must be an array");
|
|
||||||
}
|
|
||||||
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
|
|
||||||
validationErrors.push("isPolicyEnabled must be a boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
dataMaskingContent: newDataMasking,
|
|
||||||
dataMaskingValidationErrors: validationErrors,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private resetShouldDiscardDataMasking = (): void => this.setState({ shouldDiscardDataMasking: false });
|
|
||||||
|
|
||||||
private onDataMaskingDirtyChange = (isDataMaskingDirty: boolean): void =>
|
|
||||||
this.setState({ isDataMaskingDirty: isDataMaskingDirty });
|
|
||||||
|
|
||||||
private calculateTotalThroughputUsed = (): void => {
|
private calculateTotalThroughputUsed = (): void => {
|
||||||
this.totalThroughputUsed = 0;
|
this.totalThroughputUsed = 0;
|
||||||
(useDatabases.getState().databases || []).forEach(async (database) => {
|
(useDatabases.getState().databases || []).forEach(async (database) => {
|
||||||
@@ -828,11 +772,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
const fullTextPolicy: DataModels.FullTextPolicy =
|
const fullTextPolicy: DataModels.FullTextPolicy =
|
||||||
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
|
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
|
||||||
const indexingPolicyContent = this.collection.indexingPolicy();
|
const indexingPolicyContent = this.collection.indexingPolicy();
|
||||||
const dataMaskingContent: DataModels.DataMaskingPolicy = {
|
|
||||||
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
|
|
||||||
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
|
|
||||||
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled ?? true,
|
|
||||||
};
|
|
||||||
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
||||||
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
||||||
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
|
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
|
||||||
@@ -885,14 +824,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
geospatialConfigTypeBaseline: geoSpatialConfigType,
|
geospatialConfigTypeBaseline: geoSpatialConfigType,
|
||||||
computedPropertiesContent: computedPropertiesContent,
|
computedPropertiesContent: computedPropertiesContent,
|
||||||
computedPropertiesContentBaseline: computedPropertiesContent,
|
computedPropertiesContentBaseline: computedPropertiesContent,
|
||||||
dataMaskingContent: dataMaskingContent,
|
|
||||||
dataMaskingContentBaseline: dataMaskingContent,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
private getTabsButtons = (): CommandButtonComponentProps[] => {
|
private getTabsButtons = (): CommandButtonComponentProps[] => {
|
||||||
const buttons: CommandButtonComponentProps[] = [];
|
const buttons: CommandButtonComponentProps[] = [];
|
||||||
const isExecuting = this.props.settingsTab.isExecuting();
|
|
||||||
if (this.saveSettingsButton.isVisible()) {
|
if (this.saveSettingsButton.isVisible()) {
|
||||||
const label = "Save";
|
const label = "Save";
|
||||||
buttons.push({
|
buttons.push({
|
||||||
@@ -902,7 +838,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
commandButtonLabel: label,
|
commandButtonLabel: label,
|
||||||
ariaLabel: label,
|
ariaLabel: label,
|
||||||
hasPopup: false,
|
hasPopup: false,
|
||||||
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
|
disabled: !this.saveSettingsButton.isEnabled(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,16 +847,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
buttons.push({
|
buttons.push({
|
||||||
iconSrc: DiscardIcon,
|
iconSrc: DiscardIcon,
|
||||||
iconAlt: label,
|
iconAlt: label,
|
||||||
onCommandClick: () => {
|
onCommandClick: this.onRevertClick,
|
||||||
if (isExecuting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.onRevertClick();
|
|
||||||
},
|
|
||||||
commandButtonLabel: label,
|
commandButtonLabel: label,
|
||||||
ariaLabel: label,
|
ariaLabel: label,
|
||||||
hasPopup: false,
|
hasPopup: false,
|
||||||
disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(),
|
disabled: !this.discardSettingsChangesButton.isEnabled(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return buttons;
|
return buttons;
|
||||||
@@ -1018,8 +949,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
this.state.isContainerPolicyDirty ||
|
this.state.isContainerPolicyDirty ||
|
||||||
this.state.isIndexingPolicyDirty ||
|
this.state.isIndexingPolicyDirty ||
|
||||||
this.state.isConflictResolutionDirty ||
|
this.state.isConflictResolutionDirty ||
|
||||||
this.state.isComputedPropertiesDirty ||
|
this.state.isComputedPropertiesDirty
|
||||||
this.state.isDataMaskingDirty
|
|
||||||
) {
|
) {
|
||||||
let defaultTtl: number;
|
let defaultTtl: number;
|
||||||
switch (this.state.timeToLive) {
|
switch (this.state.timeToLive) {
|
||||||
@@ -1042,11 +972,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
|
|
||||||
newCollection.fullTextPolicy = this.state.fullTextPolicy;
|
newCollection.fullTextPolicy = this.state.fullTextPolicy;
|
||||||
|
|
||||||
// Only send data masking policy if it was modified (dirty)
|
|
||||||
if (this.state.isDataMaskingDirty && isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
|
|
||||||
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
||||||
|
|
||||||
newCollection.changeFeedPolicy =
|
newCollection.changeFeedPolicy =
|
||||||
@@ -1092,18 +1017,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
await this.refreshIndexTransformationProgress();
|
await this.refreshIndexTransformationProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update collection object with new data
|
|
||||||
this.collection.dataMaskingPolicy(updatedCollection.dataMaskingPolicy);
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
dataMaskingContentBaseline: this.state.dataMaskingContent,
|
|
||||||
isSubSettingsSaveable: false,
|
isSubSettingsSaveable: false,
|
||||||
isSubSettingsDiscardable: false,
|
isSubSettingsDiscardable: false,
|
||||||
isContainerPolicyDirty: false,
|
isContainerPolicyDirty: false,
|
||||||
isIndexingPolicyDirty: false,
|
isIndexingPolicyDirty: false,
|
||||||
isConflictResolutionDirty: false,
|
isConflictResolutionDirty: false,
|
||||||
isComputedPropertiesDirty: false,
|
isComputedPropertiesDirty: false,
|
||||||
isDataMaskingDirty: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1433,31 +1353,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if DDM should be enabled
|
|
||||||
const shouldEnableDDM = (): boolean => {
|
|
||||||
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
|
|
||||||
const isSqlAccount = userContext.apiType === "SQL";
|
|
||||||
|
|
||||||
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shouldEnableDDM()) {
|
|
||||||
const dataMaskingComponentProps: DataMaskingComponentProps = {
|
|
||||||
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
|
|
||||||
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,
|
|
||||||
dataMaskingContent: this.state.dataMaskingContent,
|
|
||||||
dataMaskingContentBaseline: this.state.dataMaskingContentBaseline,
|
|
||||||
onDataMaskingContentChange: this.onDataMaskingContentChange,
|
|
||||||
onDataMaskingDirtyChange: this.onDataMaskingDirtyChange,
|
|
||||||
validationErrors: this.state.dataMaskingValidationErrors,
|
|
||||||
};
|
|
||||||
|
|
||||||
tabs.push({
|
|
||||||
tab: SettingsV2TabTypes.DataMaskingTab,
|
|
||||||
content: <DataMaskingComponent {...dataMaskingComponentProps} />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
|
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
tab: SettingsV2TabTypes.ThroughputBucketsTab,
|
tab: SettingsV2TabTypes.ThroughputBucketsTab,
|
||||||
|
|||||||
@@ -69,111 +69,13 @@ describe("SettingsUtils functions", () => {
|
|||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRuPriceBreakdown", () => {
|
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
|
||||||
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
|
const prices = getRuPriceBreakdown(500, "", 1, false, false);
|
||||||
const prices = getRuPriceBreakdown(500, "", 1, false, false);
|
expect(prices.hourlyPrice).toBe(0.04);
|
||||||
expect(prices.hourlyPrice).toBe(0.04);
|
expect(prices.dailyPrice).toBe(0.96);
|
||||||
expect(prices.dailyPrice).toBe(0.96);
|
expect(prices.monthlyPrice).toBe(29.2);
|
||||||
expect(prices.monthlyPrice).toBe(29.2);
|
expect(prices.pricePerRu).toBe(0.00008);
|
||||||
expect(prices.pricePerRu).toBe(0.00008);
|
expect(prices.currency).toBe("USD");
|
||||||
expect(prices.currency).toBe("USD");
|
expect(prices.currencySign).toBe("$");
|
||||||
expect(prices.currencySign).toBe("$");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return correct price breakdown for autoscale", () => {
|
|
||||||
const prices = getRuPriceBreakdown(1000, "", 1, false, true);
|
|
||||||
// For autoscale, the baseline RU is 10% of max RU
|
|
||||||
expect(prices.hourlyPrice).toBe(0.12); // Higher because autoscale pricing is different
|
|
||||||
expect(prices.dailyPrice).toBe(2.88); // hourlyPrice * 24
|
|
||||||
expect(prices.monthlyPrice).toBe(87.6); // hourlyPrice * 730
|
|
||||||
expect(prices.pricePerRu).toBe(0.00012); // Autoscale price per RU
|
|
||||||
expect(prices.currency).toBe("USD");
|
|
||||||
expect(prices.currencySign).toBe("$");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return correct price breakdown for multimaster", () => {
|
|
||||||
const prices = getRuPriceBreakdown(500, "", 2, true, false);
|
|
||||||
// For multimaster with 2 regions, price is multiplied by 4
|
|
||||||
expect(prices.hourlyPrice).toBe(0.16); // Base price * 4
|
|
||||||
expect(prices.dailyPrice).toBe(3.84); // hourlyPrice * 24
|
|
||||||
expect(prices.monthlyPrice).toBe(116.8); // hourlyPrice * 730
|
|
||||||
expect(prices.pricePerRu).toBe(0.00016); // Base price per RU * 2 (regions) * 2 (multimaster)
|
|
||||||
expect(prices.currency).toBe("USD");
|
|
||||||
expect(prices.currencySign).toBe("$");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("message formatting", () => {
|
|
||||||
it("should format throughput apply delayed message correctly", () => {
|
|
||||||
const message = getThroughputApplyDelayedMessage(false, 1000, "RU/s", "testDb", "testColl", 2000);
|
|
||||||
const wrapper = shallow(message);
|
|
||||||
const text = wrapper.text();
|
|
||||||
expect(text).toContain("testDb");
|
|
||||||
expect(text).toContain("testColl");
|
|
||||||
expect(text).toContain("Current manual throughput: 1000 RU/s");
|
|
||||||
expect(text).toContain("Target manual throughput: 2000");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format autoscale throughput message correctly", () => {
|
|
||||||
const message = getThroughputApplyDelayedMessage(true, 1000, "RU/s", "testDb", "testColl", 2000);
|
|
||||||
const wrapper = shallow(message);
|
|
||||||
const text = wrapper.text();
|
|
||||||
expect(text).toContain("Current autoscale throughput: 100 - 1000 RU/s");
|
|
||||||
expect(text).toContain("Target autoscale throughput: 200 - 2000 RU/s");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("estimated spending element", () => {
|
|
||||||
// Mock Stack component since we're using shallow rendering
|
|
||||||
const mockStack = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.mock("@fluentui/react", () => ({
|
|
||||||
...jest.requireActual("@fluentui/react"),
|
|
||||||
Stack: mockStack,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render correct spending info for manual throughput", () => {
|
|
||||||
const costElement = <div>Cost</div>;
|
|
||||||
const priceBreakdown: PriceBreakdown = {
|
|
||||||
hourlyPrice: 1.0,
|
|
||||||
dailyPrice: 24.0,
|
|
||||||
monthlyPrice: 730.0,
|
|
||||||
pricePerRu: 0.0001,
|
|
||||||
currency: "USD",
|
|
||||||
currencySign: "$",
|
|
||||||
};
|
|
||||||
|
|
||||||
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, false);
|
|
||||||
const wrapper = shallow(element);
|
|
||||||
const spendElement = wrapper.find("#throughputSpendElement");
|
|
||||||
|
|
||||||
expect(spendElement.find("span").at(0).text()).toBe("1 region");
|
|
||||||
expect(spendElement.find("span").at(1).text()).toBe("1000 RU/s");
|
|
||||||
expect(spendElement.find("span").at(2).text()).toBe("$0.0001/RU");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render correct spending info for autoscale throughput", () => {
|
|
||||||
const costElement = <div>Cost</div>;
|
|
||||||
const priceBreakdown: PriceBreakdown = {
|
|
||||||
hourlyPrice: 1.0,
|
|
||||||
dailyPrice: 24.0,
|
|
||||||
monthlyPrice: 730.0,
|
|
||||||
pricePerRu: 0.0001,
|
|
||||||
currency: "USD",
|
|
||||||
currencySign: "$",
|
|
||||||
};
|
|
||||||
|
|
||||||
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, true);
|
|
||||||
const wrapper = shallow(element);
|
|
||||||
const spendElement = wrapper.find("#throughputSpendElement");
|
|
||||||
|
|
||||||
expect(spendElement.find("span").at(1).text()).toBe("100 RU/s - 1000 RU/s");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface PriceBreakdown {
|
|||||||
currencySign: string;
|
currencySign: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type editorType = "indexPolicy" | "computedProperties" | "dataMasking";
|
export type editorType = "indexPolicy" | "computedProperties";
|
||||||
|
|
||||||
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
|
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
|
||||||
|
|
||||||
@@ -170,14 +170,6 @@ export const messageBarStyles: Partial<IMessageBarStyles> = {
|
|||||||
text: { fontSize: 14 },
|
text: { fontSize: 14 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unsavedEditorMessageBarStyles: Partial<IMessageBarStyles> = {
|
|
||||||
root: {
|
|
||||||
marginTop: "5px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
},
|
|
||||||
text: { fontSize: 14 },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const throughputUnit = "RU/s";
|
export const throughputUnit = "RU/s";
|
||||||
|
|
||||||
export function onRenderRow(props: IDetailsRowProps): JSX.Element {
|
export function onRenderRow(props: IDetailsRowProps): JSX.Element {
|
||||||
@@ -267,12 +259,7 @@ export const ttlWarning: JSX.Element = (
|
|||||||
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
|
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
|
||||||
<Text styles={infoAndToolTipTextStyle}>
|
<Text styles={infoAndToolTipTextStyle}>
|
||||||
You have not saved the latest changes made to your{" "}
|
You have not saved the latest changes made to your{" "}
|
||||||
{editor === "indexPolicy"
|
{editor === "indexPolicy" ? "indexing policy" : "computed properties"}. Please click save to confirm the changes.
|
||||||
? "indexing policy"
|
|
||||||
: editor === "dataMasking"
|
|
||||||
? "data masking policy"
|
|
||||||
: "computed properties"}
|
|
||||||
. Please click save to confirm the changes.
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import { MessageBar, MessageBarType } from "@fluentui/react";
|
|
||||||
import { mount } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import * as DataModels from "../../../../Contracts/DataModels";
|
|
||||||
import { DataMaskingComponent } from "./DataMaskingComponent";
|
|
||||||
|
|
||||||
const mockGetValue = jest.fn();
|
|
||||||
const mockSetValue = jest.fn();
|
|
||||||
const mockOnDidChangeContent = jest.fn();
|
|
||||||
const mockGetModel = jest.fn(() => ({
|
|
||||||
getValue: mockGetValue,
|
|
||||||
setValue: mockSetValue,
|
|
||||||
onDidChangeContent: mockOnDidChangeContent,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockEditor = {
|
|
||||||
getModel: mockGetModel,
|
|
||||||
dispose: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock("../../../LazyMonaco", () => ({
|
|
||||||
loadMonaco: jest.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
editor: {
|
|
||||||
create: jest.fn(() => mockEditor),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../../../../Utils/CapabilityUtils", () => ({
|
|
||||||
isCapabilityEnabled: jest.fn().mockReturnValue(true),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("DataMaskingComponent", () => {
|
|
||||||
const mockProps = {
|
|
||||||
shouldDiscardDataMasking: false,
|
|
||||||
resetShouldDiscardDataMasking: jest.fn(),
|
|
||||||
dataMaskingContent: undefined as DataModels.DataMaskingPolicy,
|
|
||||||
dataMaskingContentBaseline: undefined as DataModels.DataMaskingPolicy,
|
|
||||||
onDataMaskingContentChange: jest.fn(),
|
|
||||||
onDataMaskingDirtyChange: jest.fn(),
|
|
||||||
validationErrors: [] as string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
const samplePolicy: DataModels.DataMaskingPolicy = {
|
|
||||||
includedPaths: [
|
|
||||||
{
|
|
||||||
path: "/test",
|
|
||||||
strategy: "Default",
|
|
||||||
startPosition: 0,
|
|
||||||
length: -1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
excludedPaths: [],
|
|
||||||
isPolicyEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let changeContentCallback: () => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
|
|
||||||
mockOnDidChangeContent.mockImplementation((callback) => {
|
|
||||||
changeContentCallback = callback;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders without crashing", async () => {
|
|
||||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
expect(wrapper.exists()).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays warning message when content is dirty", async () => {
|
|
||||||
const wrapper = mount(
|
|
||||||
<DataMaskingComponent
|
|
||||||
{...mockProps}
|
|
||||||
dataMaskingContent={samplePolicy}
|
|
||||||
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Verify editor div is rendered
|
|
||||||
const editorDiv = wrapper.find(".settingsV2Editor");
|
|
||||||
expect(editorDiv.exists()).toBeTruthy();
|
|
||||||
|
|
||||||
// Warning message should be visible when content is dirty
|
|
||||||
const messageBar = wrapper.find(MessageBar);
|
|
||||||
expect(messageBar.exists()).toBeTruthy();
|
|
||||||
expect(messageBar.prop("messageBarType")).toBe(MessageBarType.warning);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates content and dirty state on valid JSON input", async () => {
|
|
||||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Simulate valid JSON input by setting mock return value and triggering callback
|
|
||||||
const validJson = JSON.stringify(samplePolicy);
|
|
||||||
mockGetValue.mockReturnValue(validJson);
|
|
||||||
changeContentCallback();
|
|
||||||
|
|
||||||
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(samplePolicy);
|
|
||||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("doesn't update content on invalid JSON input", async () => {
|
|
||||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Simulate invalid JSON input
|
|
||||||
const invalidJson = "{invalid:json}";
|
|
||||||
mockGetValue.mockReturnValue(invalidJson);
|
|
||||||
changeContentCallback();
|
|
||||||
|
|
||||||
expect(mockProps.onDataMaskingContentChange).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets content when shouldDiscardDataMasking is true", async () => {
|
|
||||||
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
|
|
||||||
|
|
||||||
const wrapper = mount(
|
|
||||||
<DataMaskingComponent
|
|
||||||
{...mockProps}
|
|
||||||
dataMaskingContent={samplePolicy}
|
|
||||||
dataMaskingContentBaseline={baselinePolicy}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Now update props to trigger shouldDiscardDataMasking
|
|
||||||
wrapper.setProps({ shouldDiscardDataMasking: true });
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Check that reset was triggered
|
|
||||||
expect(mockProps.resetShouldDiscardDataMasking).toHaveBeenCalled();
|
|
||||||
expect(mockSetValue).toHaveBeenCalledWith(JSON.stringify(samplePolicy, undefined, 4));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recalculates dirty state when baseline changes", async () => {
|
|
||||||
const wrapper = mount(
|
|
||||||
<DataMaskingComponent
|
|
||||||
{...mockProps}
|
|
||||||
dataMaskingContent={samplePolicy}
|
|
||||||
dataMaskingContentBaseline={samplePolicy}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Update baseline to trigger componentDidUpdate
|
|
||||||
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
|
|
||||||
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
|
|
||||||
|
|
||||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates required fields in policy", async () => {
|
|
||||||
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// Test with missing required fields
|
|
||||||
const invalidPolicy: Record<string, unknown> = {
|
|
||||||
includedPaths: "not an array",
|
|
||||||
excludedPaths: [] as string[],
|
|
||||||
isPolicyEnabled: "not a boolean",
|
|
||||||
};
|
|
||||||
|
|
||||||
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
|
|
||||||
changeContentCallback();
|
|
||||||
|
|
||||||
// Parent callback should be called even with invalid data (parent will validate)
|
|
||||||
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(invalidPolicy);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maintains dirty state after multiple content changes", async () => {
|
|
||||||
const wrapper = mount(
|
|
||||||
<DataMaskingComponent
|
|
||||||
{...mockProps}
|
|
||||||
dataMaskingContent={samplePolicy}
|
|
||||||
dataMaskingContentBaseline={samplePolicy}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
// First change
|
|
||||||
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
|
|
||||||
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
|
|
||||||
changeContentCallback();
|
|
||||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
|
||||||
|
|
||||||
// Second change back to baseline
|
|
||||||
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
|
|
||||||
changeContentCallback();
|
|
||||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
|
||||||
import * as monaco from "monaco-editor";
|
|
||||||
import * as React from "react";
|
|
||||||
import * as Constants from "../../../../Common/Constants";
|
|
||||||
import * as DataModels from "../../../../Contracts/DataModels";
|
|
||||||
import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils";
|
|
||||||
import { loadMonaco } from "../../../LazyMonaco";
|
|
||||||
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
|
|
||||||
import { isDirty as isContentDirty } from "../SettingsUtils";
|
|
||||||
|
|
||||||
export interface DataMaskingComponentProps {
|
|
||||||
shouldDiscardDataMasking: boolean;
|
|
||||||
resetShouldDiscardDataMasking: () => void;
|
|
||||||
dataMaskingContent: DataModels.DataMaskingPolicy;
|
|
||||||
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
|
||||||
onDataMaskingContentChange: (dataMasking: DataModels.DataMaskingPolicy) => void;
|
|
||||||
onDataMaskingDirtyChange: (isDirty: boolean) => void;
|
|
||||||
validationErrors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataMaskingComponentState {
|
|
||||||
isDirty: boolean;
|
|
||||||
dataMaskingContentIsValid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
|
||||||
includedPaths: [
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
strategy: "Default",
|
|
||||||
startPosition: 0,
|
|
||||||
length: -1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
excludedPaths: [],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
|
|
||||||
private dataMaskingDiv = React.createRef<HTMLDivElement>();
|
|
||||||
private dataMaskingEditor: monaco.editor.IStandaloneCodeEditor;
|
|
||||||
private shouldCheckComponentIsDirty = true;
|
|
||||||
|
|
||||||
constructor(props: DataMaskingComponentProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
isDirty: false,
|
|
||||||
dataMaskingContentIsValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidUpdate(): void {
|
|
||||||
if (this.props.shouldDiscardDataMasking) {
|
|
||||||
this.resetDataMaskingEditor();
|
|
||||||
this.props.resetShouldDiscardDataMasking();
|
|
||||||
}
|
|
||||||
this.onComponentUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount(): void {
|
|
||||||
this.resetDataMaskingEditor();
|
|
||||||
this.onComponentUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private onComponentUpdate = (): void => {
|
|
||||||
if (!this.shouldCheckComponentIsDirty) {
|
|
||||||
this.shouldCheckComponentIsDirty = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.props.onDataMaskingDirtyChange(this.IsComponentDirty());
|
|
||||||
this.shouldCheckComponentIsDirty = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
public IsComponentDirty = (): boolean => {
|
|
||||||
if (
|
|
||||||
isContentDirty(this.props.dataMaskingContent, this.props.dataMaskingContentBaseline) &&
|
|
||||||
this.state.dataMaskingContentIsValid
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
private resetDataMaskingEditor = (): void => {
|
|
||||||
if (!this.dataMaskingEditor) {
|
|
||||||
this.createDataMaskingEditor();
|
|
||||||
} else {
|
|
||||||
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
|
||||||
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
|
|
||||||
dataMaskingEditorModel.setValue(value);
|
|
||||||
}
|
|
||||||
this.onComponentUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
private async createDataMaskingEditor(): Promise<void> {
|
|
||||||
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
|
|
||||||
const monaco = await loadMonaco();
|
|
||||||
this.dataMaskingEditor = monaco.editor.create(this.dataMaskingDiv.current, {
|
|
||||||
value: value,
|
|
||||||
language: "json",
|
|
||||||
automaticLayout: true,
|
|
||||||
ariaLabel: "Data Masking Policy",
|
|
||||||
fontSize: 13,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
wordWrap: "off",
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
lineNumbers: "on",
|
|
||||||
});
|
|
||||||
if (this.dataMaskingEditor) {
|
|
||||||
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
|
||||||
dataMaskingEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onEditorContentChange = (): void => {
|
|
||||||
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
|
||||||
try {
|
|
||||||
const newContent = JSON.parse(dataMaskingEditorModel.getValue()) as DataModels.DataMaskingPolicy;
|
|
||||||
|
|
||||||
// Always call parent's validation - it will handle validation and store errors
|
|
||||||
this.props.onDataMaskingContentChange(newContent);
|
|
||||||
|
|
||||||
const isDirty = isContentDirty(newContent, this.props.dataMaskingContentBaseline);
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
dataMaskingContentIsValid: this.props.validationErrors.length === 0,
|
|
||||||
isDirty,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.props.onDataMaskingDirtyChange(isDirty);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Invalid JSON - mark as invalid without propagating
|
|
||||||
this.setState({
|
|
||||||
dataMaskingContentIsValid: false,
|
|
||||||
isDirty: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirty = this.IsComponentDirty();
|
|
||||||
return (
|
|
||||||
<Stack {...titleAndInputStackProps}>
|
|
||||||
{isDirty && (
|
|
||||||
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("dataMasking")}</MessageBar>
|
|
||||||
)}
|
|
||||||
{this.props.validationErrors.length > 0 && (
|
|
||||||
<MessageBar messageBarType={MessageBarType.error}>
|
|
||||||
Validation failed: {this.props.validationErrors.join(", ")}
|
|
||||||
</MessageBar>
|
|
||||||
)}
|
|
||||||
<div className="settingsV2Editor" tabIndex={0} ref={this.dataMaskingDiv}></div>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,7 @@ import {
|
|||||||
getMongoIndexType,
|
getMongoIndexType,
|
||||||
getMongoIndexTypeText,
|
getMongoIndexTypeText,
|
||||||
getMongoNotification,
|
getMongoNotification,
|
||||||
getPartitionKeyName,
|
|
||||||
getPartitionKeyPlaceHolder,
|
|
||||||
getPartitionKeySubtext,
|
|
||||||
getPartitionKeyTooltipText,
|
|
||||||
getSanitizedInputValue,
|
getSanitizedInputValue,
|
||||||
getTabTitle,
|
|
||||||
hasDatabaseSharedThroughput,
|
hasDatabaseSharedThroughput,
|
||||||
isDirty,
|
isDirty,
|
||||||
isIndexTransforming,
|
isIndexTransforming,
|
||||||
@@ -19,7 +14,6 @@ import {
|
|||||||
MongoWildcardPlaceHolder,
|
MongoWildcardPlaceHolder,
|
||||||
parseConflictResolutionMode,
|
parseConflictResolutionMode,
|
||||||
parseConflictResolutionProcedure,
|
parseConflictResolutionProcedure,
|
||||||
SettingsV2TabTypes,
|
|
||||||
SingleFieldText,
|
SingleFieldText,
|
||||||
WildcardText,
|
WildcardText,
|
||||||
} from "./SettingsUtils";
|
} from "./SettingsUtils";
|
||||||
@@ -56,46 +50,14 @@ describe("SettingsUtils", () => {
|
|||||||
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseConflictResolutionMode", () => {
|
it("parseConflictResolutionMode", () => {
|
||||||
it("parses valid modes correctly", () => {
|
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||||
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||||
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
|
||||||
expect(parseConflictResolutionMode("Custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
|
||||||
expect(parseConflictResolutionMode("CUSTOM")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
|
||||||
expect(parseConflictResolutionMode("LastWriterWins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty/undefined input", () => {
|
|
||||||
expect(parseConflictResolutionMode(undefined)).toBeUndefined();
|
|
||||||
expect(parseConflictResolutionMode(null)).toBeUndefined();
|
|
||||||
expect(parseConflictResolutionMode("")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to LastWriterWins for invalid inputs", () => {
|
|
||||||
expect(parseConflictResolutionMode("invalid")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
|
||||||
expect(parseConflictResolutionMode("123")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseConflictResolutionProcedure", () => {
|
it("parseConflictResolutionProcedure", () => {
|
||||||
it("extracts procedure name from valid paths", () => {
|
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc");
|
||||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual(
|
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
||||||
"conflictResSproc",
|
|
||||||
);
|
|
||||||
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
|
||||||
expect(parseConflictResolutionProcedure("/dbs/mydb/colls/mycoll/sprocs/myProc")).toEqual("myProc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty/undefined input", () => {
|
|
||||||
expect(parseConflictResolutionProcedure(undefined)).toBeUndefined();
|
|
||||||
expect(parseConflictResolutionProcedure(null)).toBeUndefined();
|
|
||||||
expect(parseConflictResolutionProcedure("")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles invalid path formats", () => {
|
|
||||||
expect(parseConflictResolutionProcedure("/invalid/path")).toBeUndefined();
|
|
||||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/wrongtype/name")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isDirty", () => {
|
describe("isDirty", () => {
|
||||||
@@ -106,235 +68,68 @@ describe("SettingsUtils", () => {
|
|||||||
excludedPaths: [],
|
excludedPaths: [],
|
||||||
} as DataModels.IndexingPolicy;
|
} as DataModels.IndexingPolicy;
|
||||||
|
|
||||||
describe("primitive types", () => {
|
it("works on all types", () => {
|
||||||
it("handles strings", () => {
|
expect(isDirty("baseline", "baseline")).toEqual(false);
|
||||||
expect(isDirty("baseline", "baseline")).toBeFalsy();
|
expect(isDirty(0, 0)).toEqual(false);
|
||||||
expect(isDirty("baseline", "current")).toBeTruthy();
|
expect(isDirty(true, true)).toEqual(false);
|
||||||
expect(isDirty("", "")).toBeFalsy();
|
expect(isDirty(undefined, undefined)).toEqual(false);
|
||||||
expect(isDirty("test", "")).toBeTruthy();
|
expect(isDirty(indexingPolicy, indexingPolicy)).toEqual(false);
|
||||||
});
|
|
||||||
|
|
||||||
it("handles numbers", () => {
|
expect(isDirty("baseline", "current")).toEqual(true);
|
||||||
expect(isDirty(0, 0)).toBeFalsy();
|
expect(isDirty(0, 1)).toEqual(true);
|
||||||
expect(isDirty(1, 1)).toBeFalsy();
|
expect(isDirty(true, false)).toEqual(true);
|
||||||
expect(isDirty(0, 1)).toBeTruthy();
|
expect(isDirty(undefined, indexingPolicy)).toEqual(true);
|
||||||
expect(isDirty(-1, 1)).toBeTruthy();
|
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toEqual(true);
|
||||||
});
|
|
||||||
|
|
||||||
it("handles booleans", () => {
|
|
||||||
expect(isDirty(true, true)).toBeFalsy();
|
|
||||||
expect(isDirty(false, false)).toBeFalsy();
|
|
||||||
expect(isDirty(true, false)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles undefined and null", () => {
|
|
||||||
expect(isDirty(undefined, undefined)).toBeFalsy();
|
|
||||||
expect(isDirty(null, null)).toBeFalsy();
|
|
||||||
expect(isDirty(undefined, null)).toBeTruthy();
|
|
||||||
expect(isDirty(undefined, "value")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("complex types", () => {
|
|
||||||
it("handles indexing policy", () => {
|
|
||||||
expect(isDirty(indexingPolicy, indexingPolicy)).toBeFalsy();
|
|
||||||
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toBeTruthy();
|
|
||||||
expect(isDirty(indexingPolicy, { ...indexingPolicy, includedPaths: ["/path"] })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles array type policies", () => {
|
|
||||||
const computedProperties: DataModels.ComputedProperties = [
|
|
||||||
{ name: "prop1", query: "SELECT * FROM c" },
|
|
||||||
{ name: "prop2", query: "SELECT * FROM c" },
|
|
||||||
];
|
|
||||||
const otherProperties: DataModels.ComputedProperties = [{ name: "prop1", query: "SELECT * FROM c" }];
|
|
||||||
expect(isDirty(computedProperties, computedProperties)).toBeFalsy();
|
|
||||||
expect(isDirty(computedProperties, otherProperties)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("type mismatch handling", () => {
|
|
||||||
it("throws error for mismatched types", () => {
|
|
||||||
expect(() => isDirty("string", 123)).toThrow("current and baseline values are not of the same type");
|
|
||||||
expect(() => isDirty(true, "true")).toThrow("current and baseline values are not of the same type");
|
|
||||||
expect(() => isDirty(0, false)).toThrow("current and baseline values are not of the same type");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getSanitizedInputValue", () => {
|
it("getSanitizedInputValue", () => {
|
||||||
const max = 100;
|
const max = 100;
|
||||||
|
expect(getSanitizedInputValue("", max)).toEqual(0);
|
||||||
it("handles empty or invalid inputs", () => {
|
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
||||||
expect(getSanitizedInputValue("", max)).toEqual(0);
|
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
||||||
expect(getSanitizedInputValue("abc", max)).toEqual(0);
|
|
||||||
expect(getSanitizedInputValue("!@#", max)).toEqual(0);
|
|
||||||
expect(getSanitizedInputValue(null, max)).toEqual(0);
|
|
||||||
expect(getSanitizedInputValue(undefined, max)).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles valid inputs within max", () => {
|
|
||||||
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
|
||||||
expect(getSanitizedInputValue("50", max)).toEqual(50);
|
|
||||||
expect(getSanitizedInputValue("100", max)).toEqual(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles inputs exceeding max", () => {
|
|
||||||
expect(getSanitizedInputValue("101", max)).toEqual(100);
|
|
||||||
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
|
||||||
expect(getSanitizedInputValue("1000000", max)).toEqual(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles inputs without max constraint", () => {
|
|
||||||
expect(getSanitizedInputValue("10")).toEqual(10);
|
|
||||||
expect(getSanitizedInputValue("1000")).toEqual(1000);
|
|
||||||
expect(getSanitizedInputValue("999999")).toEqual(999999);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles negative numbers", () => {
|
|
||||||
expect(getSanitizedInputValue("-10", max)).toEqual(-10);
|
|
||||||
expect(getSanitizedInputValue("-999", max)).toEqual(-999);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getMongoIndexType", () => {
|
it("getMongoIndexType", () => {
|
||||||
it("correctly identifies single field indexes", () => {
|
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
||||||
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||||
expect(getMongoIndexType(["field1"])).toEqual(MongoIndexTypes.Single);
|
expect(getMongoIndexType(["Key1", "Key2"])).toEqual(undefined);
|
||||||
expect(getMongoIndexType(["name"])).toEqual(MongoIndexTypes.Single);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("correctly identifies wildcard indexes", () => {
|
|
||||||
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
|
||||||
expect(getMongoIndexType(["field.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
|
||||||
expect(getMongoIndexType(["nested.path.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for invalid or compound indexes", () => {
|
|
||||||
expect(getMongoIndexType(["Key1", "Key2"])).toBeUndefined();
|
|
||||||
expect(getMongoIndexType([])).toBeUndefined();
|
|
||||||
expect(getMongoIndexType(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getMongoIndexTypeText", () => {
|
it("getMongoIndexTypeText", () => {
|
||||||
it("returns correct text for single field indexes", () => {
|
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
||||||
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
||||||
});
|
|
||||||
|
|
||||||
it("returns correct text for wildcard indexes", () => {
|
|
||||||
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getMongoNotification", () => {
|
it("getMongoNotification", () => {
|
||||||
const singleIndexDescription = "sampleKey";
|
const singleIndexDescription = "sampleKey";
|
||||||
const wildcardIndexDescription = "sampleKey.$**";
|
const wildcardIndexDescription = "sampleKey.$**";
|
||||||
|
|
||||||
describe("type validation", () => {
|
let notification = getMongoNotification(singleIndexDescription, undefined);
|
||||||
it("returns warning when type is missing", () => {
|
expect(notification.message).toEqual("Please select a type for each index.");
|
||||||
const notification = getMongoNotification(singleIndexDescription, undefined);
|
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
||||||
expect(notification.message).toEqual("Please select a type for each index.");
|
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for valid type and description combinations", () => {
|
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Single);
|
||||||
expect(getMongoNotification(singleIndexDescription, MongoIndexTypes.Single)).toBeUndefined();
|
expect(notification).toEqual(undefined);
|
||||||
expect(getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("field name validation", () => {
|
notification = getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard);
|
||||||
it("returns error when field name is empty", () => {
|
expect(notification).toEqual(undefined);
|
||||||
const notification = getMongoNotification("", MongoIndexTypes.Single);
|
|
||||||
expect(notification.message).toEqual("Please enter a field name.");
|
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
|
||||||
|
|
||||||
const whitespaceNotification = getMongoNotification(" ", MongoIndexTypes.Single);
|
notification = getMongoNotification("", MongoIndexTypes.Single);
|
||||||
expect(whitespaceNotification.message).toEqual("Please enter a field name.");
|
expect(notification.message).toEqual("Please enter a field name.");
|
||||||
expect(whitespaceNotification.type).toEqual(MongoNotificationType.Error);
|
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||||
});
|
|
||||||
|
|
||||||
it("returns error when wildcard index is missing $** pattern", () => {
|
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
|
||||||
const notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
|
expect(notification.message).toEqual(
|
||||||
expect(notification.message).toEqual(
|
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
|
||||||
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
|
);
|
||||||
);
|
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles undefined field name", () => {
|
|
||||||
const notification = getMongoNotification(undefined, MongoIndexTypes.Single);
|
|
||||||
expect(notification.message).toEqual("Please enter a field name.");
|
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("isIndexingTransforming", () => {
|
|
||||||
expect(isIndexTransforming(undefined)).toBeFalsy();
|
|
||||||
expect(isIndexTransforming(0)).toBeTruthy();
|
|
||||||
expect(isIndexTransforming(90)).toBeTruthy();
|
|
||||||
expect(isIndexTransforming(100)).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getTabTitle", () => {
|
|
||||||
it("returns correct titles for each tab type", () => {
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.ScaleTab)).toBe("Scale");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.ConflictResolutionTab)).toBe("Conflict Resolution");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.SubSettingsTab)).toBe("Settings");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.IndexingPolicyTab)).toBe("Indexing Policy");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.ComputedPropertiesTab)).toBe("Computed Properties");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.ContainerVectorPolicyTab)).toBe("Container Policies");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.ThroughputBucketsTab)).toBe("Throughput Buckets");
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.DataMaskingTab)).toBe("Masking Policy (preview)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles partition key tab title based on fabric native", () => {
|
|
||||||
// Assuming initially not fabric native
|
|
||||||
expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error for unknown tab type", () => {
|
|
||||||
expect(() => getTabTitle(999 as SettingsV2TabTypes)).toThrow("Unknown tab 999");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("partition key utils", () => {
|
|
||||||
it("getPartitionKeyName returns correct name based on API type", () => {
|
|
||||||
expect(getPartitionKeyName("Mongo")).toBe("Shard key");
|
|
||||||
expect(getPartitionKeyName("SQL")).toBe("Partition key");
|
|
||||||
expect(getPartitionKeyName("Mongo", true)).toBe("shard key");
|
|
||||||
expect(getPartitionKeyName("SQL", true)).toBe("partition key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPartitionKeyTooltipText returns correct tooltip based on API type", () => {
|
|
||||||
const mongoTooltip = getPartitionKeyTooltipText("Mongo");
|
|
||||||
expect(mongoTooltip).toContain("shard key");
|
|
||||||
expect(mongoTooltip).toContain("replica sets");
|
|
||||||
|
|
||||||
const sqlTooltip = getPartitionKeyTooltipText("SQL");
|
|
||||||
expect(sqlTooltip).toContain("partition key");
|
|
||||||
expect(sqlTooltip).toContain("id is often a good choice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPartitionKeySubtext returns correct subtext", () => {
|
|
||||||
expect(getPartitionKeySubtext(true, "SQL")).toBe(
|
|
||||||
"For small workloads, the item ID is a suitable choice for the partition key.",
|
|
||||||
);
|
|
||||||
expect(getPartitionKeySubtext(true, "Mongo")).toBe(
|
|
||||||
"For small workloads, the item ID is a suitable choice for the partition key.",
|
|
||||||
);
|
|
||||||
expect(getPartitionKeySubtext(false, "SQL")).toBe("");
|
|
||||||
expect(getPartitionKeySubtext(true, "Other")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPartitionKeyPlaceHolder returns correct placeholder based on API type", () => {
|
|
||||||
expect(getPartitionKeyPlaceHolder("Mongo")).toBe("e.g., categoryId");
|
|
||||||
expect(getPartitionKeyPlaceHolder("Gremlin")).toBe("e.g., /address");
|
|
||||||
expect(getPartitionKeyPlaceHolder("SQL")).toBe("Required - first partition key e.g., /TenantId");
|
|
||||||
expect(getPartitionKeyPlaceHolder("SQL", 0)).toBe("second partition key e.g., /UserId");
|
|
||||||
expect(getPartitionKeyPlaceHolder("SQL", 1)).toBe("third partition key e.g., /SessionId");
|
|
||||||
expect(getPartitionKeyPlaceHolder("Other")).toBe("e.g., /address/zipCode");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("isIndexingTransforming", () => {
|
||||||
|
expect(isIndexTransforming(undefined)).toBeFalsy();
|
||||||
|
expect(isIndexTransforming(0)).toBeTruthy();
|
||||||
|
expect(isIndexTransforming(90)).toBeTruthy();
|
||||||
|
expect(isIndexTransforming(100)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export type isDirtyTypes =
|
|||||||
| DataModels.ComputedProperties
|
| DataModels.ComputedProperties
|
||||||
| DataModels.VectorEmbedding[]
|
| DataModels.VectorEmbedding[]
|
||||||
| DataModels.FullTextPolicy
|
| DataModels.FullTextPolicy
|
||||||
| DataModels.ThroughputBucket[]
|
| DataModels.ThroughputBucket[];
|
||||||
| DataModels.DataMaskingPolicy;
|
|
||||||
export const TtlOff = "off";
|
export const TtlOff = "off";
|
||||||
export const TtlOn = "on";
|
export const TtlOn = "on";
|
||||||
export const TtlOnNoDefault = "on-nodefault";
|
export const TtlOnNoDefault = "on-nodefault";
|
||||||
@@ -60,7 +59,6 @@ export enum SettingsV2TabTypes {
|
|||||||
ContainerVectorPolicyTab,
|
ContainerVectorPolicyTab,
|
||||||
ThroughputBucketsTab,
|
ThroughputBucketsTab,
|
||||||
GlobalSecondaryIndexTab,
|
GlobalSecondaryIndexTab,
|
||||||
DataMaskingTab,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ContainerPolicyTabTypes {
|
export enum ContainerPolicyTabTypes {
|
||||||
@@ -177,8 +175,6 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
|
|||||||
return "Throughput Buckets";
|
return "Throughput Buckets";
|
||||||
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
|
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
|
||||||
return "Global Secondary Index (Preview)";
|
return "Global Secondary Index (Preview)";
|
||||||
case SettingsV2TabTypes.DataMaskingTab:
|
|
||||||
return "Masking Policy (preview)";
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tab ${tab}`);
|
throw new Error(`Unknown tab ${tab}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,11 +65,6 @@ export const collection = {
|
|||||||
sourceCollectionId: "source1",
|
sourceCollectionId: "source1",
|
||||||
sourceCollectionRid: "rid123",
|
sourceCollectionRid: "rid123",
|
||||||
}),
|
}),
|
||||||
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
|
|
||||||
includedPaths: [],
|
|
||||||
excludedPaths: ["/excludedPath"],
|
|
||||||
isPolicyEnabled: true,
|
|
||||||
}),
|
|
||||||
readSettings: () => {
|
readSettings: () => {
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dataMaskingPolicy": [Function],
|
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
@@ -146,7 +145,6 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dataMaskingPolicy": [Function],
|
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
@@ -304,7 +302,6 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dataMaskingPolicy": [Function],
|
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
@@ -445,7 +442,6 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dataMaskingPolicy": [Function],
|
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IDropdownOption } from "@fluentui/react";
|
import { IDropdownOption } from "@fluentui/react";
|
||||||
|
|
||||||
const dataTypes = ["float32", "uint8", "int8"];
|
const dataTypes = ["float32", "float16", "uint8", "int8"];
|
||||||
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
|
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
|
||||||
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
|
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
|
||||||
|
|
||||||
|
|||||||
@@ -359,14 +359,6 @@ export default class Explorer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openContainerCopyFeedbackBlade(): Promise<void> {
|
|
||||||
sendMessage({ type: MessageTypes.OpenContainerCopyFeedbackBlade });
|
|
||||||
Logger.logInfo(
|
|
||||||
`Container Copy Feedback logging current date when survey is shown ${Date.now().toString()}`,
|
|
||||||
"Explorer/openContainerCopyFeedbackBlade",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async refreshDatabaseForResourceToken(): Promise<void> {
|
public async refreshDatabaseForResourceToken(): Promise<void> {
|
||||||
const databaseId = userContext.parsedResourceToken?.databaseId;
|
const databaseId = userContext.parsedResourceToken?.databaseId;
|
||||||
const collectionId = userContext.parsedResourceToken?.collectionId;
|
const collectionId = userContext.parsedResourceToken?.collectionId;
|
||||||
@@ -1024,7 +1016,7 @@ export default class Explorer {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ViewModels.TerminalKind.VCoreMongo:
|
case ViewModels.TerminalKind.VCoreMongo:
|
||||||
title = "Mongo Shell";
|
title = "VCoreMongo Shell";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
|||||||
|
|
||||||
describe("Open Postgres and vCore Mongo buttons", () => {
|
describe("Open Postgres and vCore Mongo buttons", () => {
|
||||||
const openPostgresShellButtonLabel = "Open PSQL shell";
|
const openPostgresShellButtonLabel = "Open PSQL shell";
|
||||||
const openVCoreMongoShellButtonLabel = "Open MongoDB (DocumentDB) shell";
|
const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockExplorer = {} as Explorer;
|
mockExplorer = {} as Explorer;
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ function createOpenTerminalButtonByKind(
|
|||||||
case ViewModels.TerminalKind.Postgres:
|
case ViewModels.TerminalKind.Postgres:
|
||||||
return "PSQL";
|
return "PSQL";
|
||||||
case ViewModels.TerminalKind.VCoreMongo:
|
case ViewModels.TerminalKind.VCoreMongo:
|
||||||
return "MongoDB (DocumentDB)";
|
return "MongoDB (vCore)";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import InfoIcon from "../../../../images/info_color.svg";
|
|||||||
import LoadingIcon from "../../../../images/loading.svg";
|
import LoadingIcon from "../../../../images/loading.svg";
|
||||||
import WarningIcon from "../../../../images/warning.svg";
|
import WarningIcon from "../../../../images/warning.svg";
|
||||||
import { ClientDefaults, KeyCodes } from "../../../Common/Constants";
|
import { ClientDefaults, KeyCodes } from "../../../Common/Constants";
|
||||||
|
import { userContext } from "../../../UserContext";
|
||||||
import { useNotificationConsole } from "../../../hooks/useNotificationConsole";
|
import { useNotificationConsole } from "../../../hooks/useNotificationConsole";
|
||||||
import { ConsoleData, ConsoleDataType } from "./ConsoleData";
|
import { ConsoleData, ConsoleDataType } from "./ConsoleData";
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ export class NotificationConsoleComponent extends React.Component<
|
|||||||
<span className="numWarningItems">{numWarningItems}</span>
|
<span className="numWarningItems">{numWarningItems}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{userContext.features.pr && <PrPreview pr={userContext.features.pr} />}
|
||||||
<span className="consoleSplitter" />
|
<span className="consoleSplitter" />
|
||||||
<span className="headerStatus">
|
<span className="headerStatus">
|
||||||
<span className="headerStatusEllipsis" aria-live="assertive" aria-atomic="true">
|
<span className="headerStatusEllipsis" aria-live="assertive" aria-atomic="true">
|
||||||
@@ -291,6 +293,21 @@ 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 = () => {
|
export const NotificationConsole: React.FC = () => {
|
||||||
const setIsExpanded = useNotificationConsole((state) => state.setIsExpanded);
|
const setIsExpanded = useNotificationConsole((state) => state.setIsExpanded);
|
||||||
const isExpanded = useNotificationConsole((state) => state.isExpanded);
|
const isExpanded = useNotificationConsole((state) => state.isExpanded);
|
||||||
|
|||||||
@@ -893,7 +893,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
) => {
|
) => {
|
||||||
this.setState({ fullTextPolicy, fullTextIndexes, fullTextPolicyValidated });
|
this.setState({ fullTextPolicy, fullTextIndexes, fullTextPolicyValidated });
|
||||||
}}
|
}}
|
||||||
// Remove when multi language support on container create issue is fixed
|
|
||||||
englishOnly={true}
|
englishOnly={true}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -516,7 +516,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FullTextPoliciesComponent
|
<FullTextPoliciesComponent
|
||||||
englishOnly={true}
|
|
||||||
fullTextPolicy={
|
fullTextPolicy={
|
||||||
{
|
{
|
||||||
"defaultLanguage": "en-US",
|
"defaultLanguage": "en-US",
|
||||||
|
|||||||
@@ -3,24 +3,11 @@ import React from "react";
|
|||||||
import { PanelContainerComponent, PanelContainerProps } from "./PanelContainerComponent";
|
import { PanelContainerComponent, PanelContainerProps } from "./PanelContainerComponent";
|
||||||
|
|
||||||
describe("PaneContainerComponent test", () => {
|
describe("PaneContainerComponent test", () => {
|
||||||
it("should not render console with panel", () => {
|
|
||||||
const panelContainerProps: PanelContainerProps = {
|
|
||||||
headerText: "test",
|
|
||||||
panelContent: <div></div>,
|
|
||||||
isOpen: true,
|
|
||||||
hasConsole: false,
|
|
||||||
isConsoleExpanded: false,
|
|
||||||
};
|
|
||||||
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render with panel content and header", () => {
|
it("should render with panel content and header", () => {
|
||||||
const panelContainerProps: PanelContainerProps = {
|
const panelContainerProps: PanelContainerProps = {
|
||||||
headerText: "test",
|
headerText: "test",
|
||||||
panelContent: <div></div>,
|
panelContent: <div></div>,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
hasConsole: true,
|
|
||||||
isConsoleExpanded: false,
|
isConsoleExpanded: false,
|
||||||
};
|
};
|
||||||
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
||||||
@@ -32,7 +19,6 @@ describe("PaneContainerComponent test", () => {
|
|||||||
headerText: "test",
|
headerText: "test",
|
||||||
panelContent: undefined,
|
panelContent: undefined,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
hasConsole: true,
|
|
||||||
isConsoleExpanded: false,
|
isConsoleExpanded: false,
|
||||||
};
|
};
|
||||||
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
||||||
@@ -44,7 +30,6 @@ describe("PaneContainerComponent test", () => {
|
|||||||
headerText: "test",
|
headerText: "test",
|
||||||
panelContent: <div></div>,
|
panelContent: <div></div>,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
hasConsole: true,
|
|
||||||
isConsoleExpanded: true,
|
isConsoleExpanded: true,
|
||||||
};
|
};
|
||||||
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export interface PanelContainerProps {
|
|||||||
panelContent?: JSX.Element;
|
panelContent?: JSX.Element;
|
||||||
isConsoleExpanded: boolean;
|
isConsoleExpanded: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
hasConsole: boolean;
|
|
||||||
isConsoleAnimationFinished?: boolean;
|
isConsoleAnimationFinished?: boolean;
|
||||||
panelWidth?: string;
|
panelWidth?: string;
|
||||||
onRenderNavigationContent?: IRenderFunction<IPanelProps>;
|
onRenderNavigationContent?: IRenderFunction<IPanelProps>;
|
||||||
@@ -87,9 +86,6 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
private getPanelHeight = (): string => {
|
private getPanelHeight = (): string => {
|
||||||
if (!this.props.hasConsole) {
|
|
||||||
return window.innerHeight + "px";
|
|
||||||
}
|
|
||||||
const notificationConsole = document.getElementById("explorerNotificationConsole");
|
const notificationConsole = document.getElementById("explorerNotificationConsole");
|
||||||
if (notificationConsole) {
|
if (notificationConsole) {
|
||||||
return window.innerHeight - notificationConsole.clientHeight + "px";
|
return window.innerHeight - notificationConsole.clientHeight + "px";
|
||||||
@@ -106,10 +102,9 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
|
|||||||
export const SidePanel: React.FC = () => {
|
export const SidePanel: React.FC = () => {
|
||||||
const isConsoleExpanded = useNotificationConsole((state) => state.isExpanded);
|
const isConsoleExpanded = useNotificationConsole((state) => state.isExpanded);
|
||||||
const isConsoleAnimationFinished = useNotificationConsole((state) => state.consoleAnimationFinished);
|
const isConsoleAnimationFinished = useNotificationConsole((state) => state.consoleAnimationFinished);
|
||||||
const { isOpen, hasConsole, panelContent, panelWidth, headerText } = useSidePanel((state) => {
|
const { isOpen, panelContent, panelWidth, headerText } = useSidePanel((state) => {
|
||||||
return {
|
return {
|
||||||
isOpen: state.isOpen,
|
isOpen: state.isOpen,
|
||||||
hasConsole: state.hasConsole,
|
|
||||||
panelContent: state.panelContent,
|
panelContent: state.panelContent,
|
||||||
headerText: state.headerText,
|
headerText: state.headerText,
|
||||||
panelWidth: state.panelWidth,
|
panelWidth: state.panelWidth,
|
||||||
@@ -119,7 +114,6 @@ export const SidePanel: React.FC = () => {
|
|||||||
// This component only exists so we can use hooks and pass them down to a non-functional component
|
// This component only exists so we can use hooks and pass them down to a non-functional component
|
||||||
return (
|
return (
|
||||||
<PanelContainerComponent
|
<PanelContainerComponent
|
||||||
hasConsole={hasConsole}
|
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
panelContent={panelContent}
|
panelContent={panelContent}
|
||||||
headerText={headerText}
|
headerText={headerText}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
IToggleStyles,
|
IToggleStyles,
|
||||||
Position,
|
Position,
|
||||||
SpinButton,
|
SpinButton,
|
||||||
Stack,
|
|
||||||
Toggle,
|
Toggle,
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
||||||
@@ -1164,20 +1163,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
</AccordionHeader>
|
</AccordionHeader>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<div className={styles.settingsSectionContainer}>
|
<div className={styles.settingsSectionContainer}>
|
||||||
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 4 }}>
|
<Checkbox
|
||||||
<Checkbox
|
styles={{ label: { padding: 0 } }}
|
||||||
styles={{ label: { padding: 0 } }}
|
className="padding"
|
||||||
className="padding"
|
ariaLabel="Ignore partition key on document update"
|
||||||
ariaLabel="Ignore partition key on document update"
|
checked={ignorePartitionKeyOnDocumentUpdate}
|
||||||
checked={ignorePartitionKeyOnDocumentUpdate}
|
onChange={handleOnIgnorePartitionKeyOnDocumentUpdateChange}
|
||||||
onChange={handleOnIgnorePartitionKeyOnDocumentUpdateChange}
|
label="Ignore partition key on document update"
|
||||||
label="Ignore partition key on document update"
|
/>
|
||||||
/>
|
|
||||||
<InfoTooltip className={styles.headerIcon}>
|
|
||||||
If checked, the partition key value will not be used to locate the document during update
|
|
||||||
operations. Only use this if document updates are failing due to an abnormal partition key.
|
|
||||||
</InfoTooltip>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -589,35 +589,20 @@ exports[`Settings Pane should render Default properly 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="___1dfa554_0000000 fo7qwa0"
|
className="___1dfa554_0000000 fo7qwa0"
|
||||||
>
|
>
|
||||||
<Stack
|
<StyledCheckboxBase
|
||||||
horizontal={true}
|
ariaLabel="Ignore partition key on document update"
|
||||||
tokens={
|
checked={false}
|
||||||
|
className="padding"
|
||||||
|
label="Ignore partition key on document update"
|
||||||
|
onChange={[Function]}
|
||||||
|
styles={
|
||||||
{
|
{
|
||||||
"childrenGap": 4,
|
"label": {
|
||||||
|
"padding": 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
verticalAlign="center"
|
/>
|
||||||
>
|
|
||||||
<StyledCheckboxBase
|
|
||||||
ariaLabel="Ignore partition key on document update"
|
|
||||||
checked={false}
|
|
||||||
className="padding"
|
|
||||||
label="Ignore partition key on document update"
|
|
||||||
onChange={[Function]}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"label": {
|
|
||||||
"padding": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoTooltip
|
|
||||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
|
||||||
>
|
|
||||||
If checked, the partition key value will not be used to locate the document during update operations. Only use this if document updates are failing due to an abnormal partition key.
|
|
||||||
</InfoTooltip>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -898,35 +883,20 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
|||||||
<div
|
<div
|
||||||
className="___1dfa554_0000000 fo7qwa0"
|
className="___1dfa554_0000000 fo7qwa0"
|
||||||
>
|
>
|
||||||
<Stack
|
<StyledCheckboxBase
|
||||||
horizontal={true}
|
ariaLabel="Ignore partition key on document update"
|
||||||
tokens={
|
checked={false}
|
||||||
|
className="padding"
|
||||||
|
label="Ignore partition key on document update"
|
||||||
|
onChange={[Function]}
|
||||||
|
styles={
|
||||||
{
|
{
|
||||||
"childrenGap": 4,
|
"label": {
|
||||||
|
"padding": 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
verticalAlign="center"
|
/>
|
||||||
>
|
|
||||||
<StyledCheckboxBase
|
|
||||||
ariaLabel="Ignore partition key on document update"
|
|
||||||
checked={false}
|
|
||||||
className="padding"
|
|
||||||
label="Ignore partition key on document update"
|
|
||||||
onChange={[Function]}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"label": {
|
|
||||||
"padding": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoTooltip
|
|
||||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
|
||||||
>
|
|
||||||
If checked, the partition key value will not be used to locate the document during update operations. Only use this if document updates are failing due to an abnormal partition key.
|
|
||||||
</InfoTooltip>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { Upload } from "Common/Upload/Upload";
|
import { Upload } from "Common/Upload/Upload";
|
||||||
import { UploadDetailsRecord } from "Contracts/ViewModels";
|
import { UploadDetailsRecord } from "Contracts/ViewModels";
|
||||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||||
import React, { ChangeEvent, FunctionComponent, useReducer, useState } from "react";
|
import React, { ChangeEvent, FunctionComponent, useState } from "react";
|
||||||
import { getErrorMessage } from "../../Tables/Utilities";
|
import { getErrorMessage } from "../../Tables/Utilities";
|
||||||
import { useSelectedNode } from "../../useSelectedNode";
|
import { useSelectedNode } from "../../useSelectedNode";
|
||||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||||
@@ -57,7 +57,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
|||||||
const [uploadFileData, setUploadFileData] = useState<UploadDetailsRecord[]>([]);
|
const [uploadFileData, setUploadFileData] = useState<UploadDetailsRecord[]>([]);
|
||||||
const [formError, setFormError] = useState<string>("");
|
const [formError, setFormError] = useState<string>("");
|
||||||
const [isExecuting, setIsExecuting] = useState<boolean>();
|
const [isExecuting, setIsExecuting] = useState<boolean>();
|
||||||
const [reducer, setReducer] = useReducer((x) => x + 1, 1);
|
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
setFormError("");
|
setFormError("");
|
||||||
@@ -76,7 +75,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
|||||||
(uploadDetails) => {
|
(uploadDetails) => {
|
||||||
setUploadFileData(uploadDetails.data);
|
setUploadFileData(uploadDetails.data);
|
||||||
setFiles(undefined);
|
setFiles(undefined);
|
||||||
setReducer(); // Trigger a re-render to update the UI with new upload details
|
|
||||||
// Emit the upload details to the parent component
|
// Emit the upload details to the parent component
|
||||||
onUpload && onUpload(uploadDetails.data);
|
onUpload && onUpload(uploadDetails.data);
|
||||||
},
|
},
|
||||||
@@ -97,7 +95,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
|||||||
const props: RightPaneFormProps = {
|
const props: RightPaneFormProps = {
|
||||||
formError,
|
formError,
|
||||||
isExecuting: isExecuting,
|
isExecuting: isExecuting,
|
||||||
isSubmitButtonDisabled: !files || files.length === 0,
|
|
||||||
submitButtonText: "Upload",
|
submitButtonText: "Upload",
|
||||||
onSubmit,
|
onSubmit,
|
||||||
};
|
};
|
||||||
@@ -195,7 +192,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
|||||||
<RightPaneForm {...props}>
|
<RightPaneForm {...props}>
|
||||||
<div className="paneMainContent">
|
<div className="paneMainContent">
|
||||||
<Upload
|
<Upload
|
||||||
key={reducer} // Force re-render on state change
|
|
||||||
label="Select JSON Files"
|
label="Select JSON Files"
|
||||||
onUpload={updateSelectedFiles}
|
onUpload={updateSelectedFiles}
|
||||||
accept="application/json"
|
accept="application/json"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
exports[`Upload Items Pane should render Default properly 1`] = `
|
exports[`Upload Items Pane should render Default properly 1`] = `
|
||||||
<RightPaneForm
|
<RightPaneForm
|
||||||
formError=""
|
formError=""
|
||||||
isSubmitButtonDisabled={true}
|
|
||||||
onSubmit={[Function]}
|
onSubmit={[Function]}
|
||||||
submitButtonText="Upload"
|
submitButtonText="Upload"
|
||||||
>
|
>
|
||||||
@@ -12,7 +11,6 @@ exports[`Upload Items Pane should render Default properly 1`] = `
|
|||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
accept="application/json"
|
accept="application/json"
|
||||||
key="1"
|
|
||||||
label="Select JSON Files"
|
label="Select JSON Files"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
onUpload={[Function]}
|
onUpload={[Function]}
|
||||||
|
|||||||
@@ -39,45 +39,6 @@ exports[`PaneContainerComponent test should be resize if notification console is
|
|||||||
</StyledPanelBase>
|
</StyledPanelBase>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`PaneContainerComponent test should not render console with panel 1`] = `
|
|
||||||
<StyledPanelBase
|
|
||||||
closeButtonAriaLabel="Close test"
|
|
||||||
customWidth="440px"
|
|
||||||
data-test="Panel:test"
|
|
||||||
headerClassName="panelHeader"
|
|
||||||
headerText="test"
|
|
||||||
isFooterAtBottom={true}
|
|
||||||
isLightDismiss={true}
|
|
||||||
isOpen={true}
|
|
||||||
onDismiss={[Function]}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"height": "768px",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"commands": {
|
|
||||||
"marginTop": 8,
|
|
||||||
"paddingTop": 0,
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"padding": 0,
|
|
||||||
},
|
|
||||||
"header": {
|
|
||||||
"padding": "0 0 8px 34px",
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"borderBottom": "1px solid #cccccc",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type={7}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</StyledPanelBase>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`PaneContainerComponent test should render nothing if content is undefined 1`] = `<Fragment />`;
|
exports[`PaneContainerComponent test should render nothing if content is undefined 1`] = `<Fragment />`;
|
||||||
|
|
||||||
exports[`PaneContainerComponent test should render with panel content and header 1`] = `
|
exports[`PaneContainerComponent test should render with panel content and header 1`] = `
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
|||||||
<Stack style={{ paddingTop: 8, height: "100%", width: "100%" }}>
|
<Stack style={{ paddingTop: 8, height: "100%", width: "100%" }}>
|
||||||
<Stack style={{ flexGrow: 1, padding: "0 20px", overflow: "auto" }}>
|
<Stack style={{ flexGrow: 1, padding: "0 20px", overflow: "auto" }}>
|
||||||
<Text variant="xxLarge">Quick start guide</Text>
|
<Text variant="xxLarge">Quick start guide</Text>
|
||||||
<Text variant="small">Getting started in Azure DocumentDB (with MongoDB compatibility)</Text>
|
<Text variant="small">Getting started in Cosmos DB Mongo DB (vCore)</Text>
|
||||||
{currentStep < 5 && (
|
{currentStep < 5 && (
|
||||||
<Pivot
|
<Pivot
|
||||||
style={{ marginTop: 10, width: "100%" }}
|
style={{ marginTop: 10, width: "100%" }}
|
||||||
@@ -68,13 +68,13 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
|||||||
This tutorial guides you to create and query distributed tables using a sample dataset.
|
This tutorial guides you to create and query distributed tables using a sample dataset.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
To start, input the admin password you used during the cluster creation process into the Document DB
|
To start, input the admin password you used during the cluster creation process into the MongoDB vCore
|
||||||
terminal.
|
terminal.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Note: If you navigate out of the Quick start blade (MongoDB Shell), the session will be closed
|
Note: If you navigate out of the Quick start blade (MongoDB vCore Shell), the session will be
|
||||||
and all ongoing commands might be interrupted.
|
closed and all ongoing commands might be interrupted.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</PivotItem>
|
</PivotItem>
|
||||||
@@ -295,7 +295,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
|
|||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Modernize your data seamlessly from an existing MongoDB cluster, whether it's on-premises or
|
Modernize your data seamlessly from an existing MongoDB cluster, whether it's on-premises or
|
||||||
hosted in the cloud, to Azure DocumentDB.
|
hosted in the cloud, to Azure Cosmos DB for MongoDB vCore.
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options"
|
href="https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options"
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Sample Vector Data",
|
title: "Sample Vector Data",
|
||||||
description: "Load sample vector data with text-embedding-ada-002",
|
description: "Load sample vector data in your database",
|
||||||
icon: <img src={AzureOpenAiIcon} alt={"Azure Open AI icon"} aria-hidden="true" />,
|
icon: <img src={AzureOpenAiIcon} alt={"Azure Open AI icon"} aria-hidden="true" />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedSampleDataConfiguration({
|
setSelectedSampleDataConfiguration({
|
||||||
@@ -203,7 +203,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
|||||||
title: "Sample Gallery",
|
title: "Sample Gallery",
|
||||||
description: "Get real-world end-to-end samples",
|
description: "Get real-world end-to-end samples",
|
||||||
icon: <img src={GithubIcon} alt={"GitHub icon"} aria-hidden="true" />,
|
icon: <img src={GithubIcon} alt={"GitHub icon"} aria-hidden="true" />,
|
||||||
onClick: () => window.open("https://aka.ms/CosmosFabricSamplesGallery", "_blank"),
|
onClick: () => window.open("https://azurecosmosdb.github.io/gallery/?tags=example&tags=analytics", "_blank"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -36,56 +36,6 @@ export enum SampleDataFile {
|
|||||||
FABRIC_SAMPLE_VECTOR_DATA = "FabricSampleVectorData",
|
FABRIC_SAMPLE_VECTOR_DATA = "FabricSampleVectorData",
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerSettings: {
|
|
||||||
[key in SampleDataFile]: {
|
|
||||||
partitionKeyString: string;
|
|
||||||
vectorEmbeddingPolicy?: DataModels.VectorEmbeddingPolicy;
|
|
||||||
indexingPolicy?: DataModels.IndexingPolicy;
|
|
||||||
};
|
|
||||||
} = {
|
|
||||||
[SampleDataFile.COPILOT]: {
|
|
||||||
partitionKeyString: "category",
|
|
||||||
},
|
|
||||||
[SampleDataFile.FABRIC_SAMPLE_DATA]: {
|
|
||||||
partitionKeyString: "categoryName",
|
|
||||||
},
|
|
||||||
[SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA]: {
|
|
||||||
partitionKeyString: "categoryName",
|
|
||||||
vectorEmbeddingPolicy: {
|
|
||||||
vectorEmbeddings: [
|
|
||||||
{
|
|
||||||
path: "/vectors",
|
|
||||||
dataType: "float32",
|
|
||||||
distanceFunction: "cosine",
|
|
||||||
dimensions: 1536,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
indexingPolicy: {
|
|
||||||
automatic: true,
|
|
||||||
indexingMode: "consistent",
|
|
||||||
includedPaths: [
|
|
||||||
{
|
|
||||||
path: "/*",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
excludedPaths: [
|
|
||||||
{
|
|
||||||
path: '/"_etag"/?',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
fullTextIndexes: [],
|
|
||||||
vectorIndexes: [
|
|
||||||
{
|
|
||||||
path: "/vectors",
|
|
||||||
type: "quantizedFlat",
|
|
||||||
quantizationByteSize: 64,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createContainer = async (
|
export const createContainer = async (
|
||||||
databaseName: string,
|
databaseName: string,
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -99,12 +49,48 @@ export const createContainer = async (
|
|||||||
databaseId: databaseName,
|
databaseId: databaseName,
|
||||||
databaseLevelThroughput: false,
|
databaseLevelThroughput: false,
|
||||||
partitionKey: {
|
partitionKey: {
|
||||||
paths: [`/${containerSettings[sampleDataFile].partitionKeyString}`],
|
paths: [`/${SAMPLE_DATA_PARTITION_KEY}`],
|
||||||
kind: "Hash",
|
kind: "Hash",
|
||||||
version: BackendDefaults.partitionKeyVersion,
|
version: BackendDefaults.partitionKeyVersion,
|
||||||
},
|
},
|
||||||
vectorEmbeddingPolicy: containerSettings[sampleDataFile].vectorEmbeddingPolicy,
|
vectorEmbeddingPolicy:
|
||||||
indexingPolicy: containerSettings[sampleDataFile].indexingPolicy,
|
sampleDataFile === SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA
|
||||||
|
? {
|
||||||
|
vectorEmbeddings: [
|
||||||
|
{
|
||||||
|
path: "/descriptionVector",
|
||||||
|
dataType: "float32",
|
||||||
|
distanceFunction: "cosine",
|
||||||
|
dimensions: 512,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
indexingPolicy:
|
||||||
|
sampleDataFile === SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA
|
||||||
|
? {
|
||||||
|
automatic: true,
|
||||||
|
indexingMode: "consistent",
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: [
|
||||||
|
{
|
||||||
|
path: '/"_etag"/?',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fullTextIndexes: [],
|
||||||
|
vectorIndexes: [
|
||||||
|
{
|
||||||
|
path: "/descriptionVector",
|
||||||
|
type: "quantizedFlat",
|
||||||
|
quantizationByteSize: 64,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
await createCollection(createRequest);
|
await createCollection(createRequest);
|
||||||
await explorer.refreshAllDatabases();
|
await explorer.refreshAllDatabases();
|
||||||
@@ -117,6 +103,8 @@ export const createContainer = async (
|
|||||||
return newCollection;
|
return newCollection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SAMPLE_DATA_PARTITION_KEY = "category"; // This pkey is specifically set for queryCopilotSampleData.json below
|
||||||
|
|
||||||
export const importData = async (sampleDataFile: SampleDataFile, collection: ViewModels.Collection): Promise<void> => {
|
export const importData = async (sampleDataFile: SampleDataFile, collection: ViewModels.Collection): Promise<void> => {
|
||||||
let documents: JSONObject[] = undefined;
|
let documents: JSONObject[] = undefined;
|
||||||
switch (sampleDataFile) {
|
switch (sampleDataFile) {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
|
|||||||
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
|
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
|
||||||
break;
|
break;
|
||||||
case "VCoreMongo":
|
case "VCoreMongo":
|
||||||
title = "Welcome to Azure DocumentDB (with MongoDB compatibility)";
|
title = "Welcome to Azure Cosmos DB for MongoDB (vCore)";
|
||||||
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
|
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -456,7 +456,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
|
|||||||
if (userContext.apiType === "VCoreMongo") {
|
if (userContext.apiType === "VCoreMongo") {
|
||||||
icon = VisualStudioIcon;
|
icon = VisualStudioIcon;
|
||||||
title = "Connect with VS Code";
|
title = "Connect with VS Code";
|
||||||
description = "Query and Manage your MongoDB and DocumentDB clusters in Visual Studio Code";
|
description = "Query and Manage your MongoDB cluster in Visual Studio Code";
|
||||||
onClick = () => this.container.openInVsCode();
|
onClick = () => this.container.openInVsCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,30 +14,17 @@ describe("Collection", () => {
|
|||||||
defaultTtl: 1,
|
defaultTtl: 1,
|
||||||
indexingPolicy: {} as DataModels.IndexingPolicy,
|
indexingPolicy: {} as DataModels.IndexingPolicy,
|
||||||
partitionKey,
|
partitionKey,
|
||||||
_rid: "testRid",
|
_rid: "",
|
||||||
_self: "testSelf",
|
_self: "",
|
||||||
_etag: "testEtag",
|
_etag: "",
|
||||||
_ts: 1,
|
_ts: 1,
|
||||||
id: "testCollection",
|
id: "",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => {
|
const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => {
|
||||||
const mockContainer = {
|
const mockContainer = {} as Explorer;
|
||||||
isReadOnly: () => false,
|
return generateCollection(mockContainer, "abc", data);
|
||||||
isFabricCapable: () => true,
|
|
||||||
databaseAccount: () => ({
|
|
||||||
name: () => "testAccount",
|
|
||||||
id: () => "testAccount",
|
|
||||||
properties: {
|
|
||||||
enablePartitionKey: true,
|
|
||||||
partitionKeyDefinitionVersion: 2,
|
|
||||||
capabilities: [] as string[],
|
|
||||||
databaseAccountEndpoint: "test.documents.azure.com",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
} as unknown as Explorer;
|
|
||||||
return generateCollection(mockContainer, "testDb", data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Partition key path parsing", () => {
|
describe("Partition key path parsing", () => {
|
||||||
@@ -91,7 +78,7 @@ describe("Collection", () => {
|
|||||||
expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey");
|
expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be empty if there is no partition key", () => {
|
it("should be null if there is no partition key", () => {
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
version: 2,
|
version: 2,
|
||||||
paths: [],
|
paths: [],
|
||||||
@@ -101,103 +88,4 @@ describe("Collection", () => {
|
|||||||
expect(collection.partitionKeyPropertyHeaders.length).toBe(0);
|
expect(collection.partitionKeyPropertyHeaders.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Collection properties", () => {
|
|
||||||
let collection: Collection;
|
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: ["/id"],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return correct collection id", () => {
|
|
||||||
expect(collection.id()).toBe("testCollection");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return correct rid", () => {
|
|
||||||
expect(collection.rid).toBe("testRid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return correct self", () => {
|
|
||||||
expect(collection.self).toBe("testSelf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return correct collection type", () => {
|
|
||||||
expect(collection.partitionKey).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Collection type", () => {
|
|
||||||
it("should identify large partitioned collection for v2 partition key", () => {
|
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: ["/id"],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 2,
|
|
||||||
});
|
|
||||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
|
||||||
expect(collection.partitionKey.version).toBe(2);
|
|
||||||
expect(collection.partitionKey.paths).toEqual(["/id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should identify standard partitioned collection for v1 partition key", () => {
|
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: ["/id"],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 1,
|
|
||||||
});
|
|
||||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
|
||||||
expect(collection.partitionKey.version).toBe(1);
|
|
||||||
expect(collection.partitionKey.paths).toEqual(["/id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should identify collection without partition key", () => {
|
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: [],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 2,
|
|
||||||
});
|
|
||||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
|
||||||
expect(collection.partitionKey.paths).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Partition key handling", () => {
|
|
||||||
it("should return correct partition key paths for multiple paths", () => {
|
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: ["/id", "/pk"],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 2,
|
|
||||||
});
|
|
||||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
|
||||||
expect(collection.partitionKey.paths).toEqual(["/id", "/pk"]);
|
|
||||||
expect(collection.partitionKeyProperties).toEqual(["id", "pk"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty partition key paths", () => {
|
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: [],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 2,
|
|
||||||
});
|
|
||||||
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
|
||||||
expect(collection.partitionKey.paths).toEqual([]);
|
|
||||||
expect(collection.partitionKeyProperties).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle undefined partition key", () => {
|
|
||||||
const collectionData = generateMockCollectionsDataModelWithPartitionKey({
|
|
||||||
paths: ["/id"],
|
|
||||||
kind: "Hash",
|
|
||||||
version: 2,
|
|
||||||
});
|
|
||||||
delete collectionData.partitionKey;
|
|
||||||
const collection = generateMockCollectionWithDataModel(collectionData);
|
|
||||||
expect(collection.partitionKey).toBeUndefined();
|
|
||||||
expect(collection.partitionKeyProperties).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
|
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
|
||||||
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
|
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
|
||||||
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
|
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
|
||||||
public dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
|
|
||||||
|
|
||||||
public offer: ko.Observable<DataModels.Offer>;
|
public offer: ko.Observable<DataModels.Offer>;
|
||||||
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
|
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
|
||||||
@@ -137,35 +136,25 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
this.materializedViews = ko.observable(data.materializedViews);
|
this.materializedViews = ko.observable(data.materializedViews);
|
||||||
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
|
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
|
||||||
|
|
||||||
// Initialize dataMaskingPolicy with default values if not present
|
this.partitionKeyPropertyHeaders = this.partitionKey?.paths;
|
||||||
const defaultDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
|
||||||
includedPaths: Array<{ path: string; strategy: string; startPosition: number; length: number }>(),
|
// TODO fix this to only replace non-excaped single quotes
|
||||||
excludedPaths: Array<string>(),
|
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
|
||||||
isPolicyEnabled: true,
|
|
||||||
};
|
|
||||||
const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy);
|
|
||||||
observablePolicy.subscribe(() => {});
|
|
||||||
this.dataMaskingPolicy = observablePolicy;
|
|
||||||
this.partitionKeyPropertyHeaders = this.partitionKey?.paths || [];
|
|
||||||
this.partitionKeyProperties =
|
|
||||||
this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
|
|
||||||
// TODO fix this to only replace non-excaped single quotes
|
|
||||||
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
|
|
||||||
|
|
||||||
if (userContext.apiType === "Mongo" && partitionKeyProperty) {
|
if (userContext.apiType === "Mongo" && partitionKeyProperty) {
|
||||||
if (~partitionKeyProperty.indexOf(`"`)) {
|
if (~partitionKeyProperty.indexOf(`"`)) {
|
||||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||||
}
|
|
||||||
// TODO #10738269 : Add this logic in a derived class for Mongo
|
|
||||||
if (partitionKeyProperty.indexOf("$v") > -1) {
|
|
||||||
// From $v.shard.$v.key.$v > shard.key
|
|
||||||
partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
|
|
||||||
this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// TODO #10738269 : Add this logic in a derived class for Mongo
|
||||||
|
if (partitionKeyProperty.indexOf("$v") > -1) {
|
||||||
|
// From $v.shard.$v.key.$v > shard.key
|
||||||
|
partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
|
||||||
|
this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return partitionKeyProperty;
|
return partitionKeyProperty;
|
||||||
}) || [];
|
});
|
||||||
|
|
||||||
this.documentIds = ko.observableArray<DocumentId>([]);
|
this.documentIds = ko.observableArray<DocumentId>([]);
|
||||||
this.isCollectionExpanded = ko.observable<boolean>(false);
|
this.isCollectionExpanded = ko.observable<boolean>(false);
|
||||||
@@ -174,6 +163,7 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
|
|
||||||
this.documentsFocused = ko.observable<boolean>();
|
this.documentsFocused = ko.observable<boolean>();
|
||||||
this.documentsFocused.subscribe((focus) => {
|
this.documentsFocused.subscribe((focus) => {
|
||||||
|
console.log("Focus set on Documents: " + focus);
|
||||||
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
46
src/Main.tsx
46
src/Main.tsx
@@ -20,12 +20,9 @@ import "../externals/jquery.typeahead.min.css";
|
|||||||
import "../externals/jquery.typeahead.min.js";
|
import "../externals/jquery.typeahead.min.js";
|
||||||
// Image Dependencies
|
// Image Dependencies
|
||||||
import { Platform } from "ConfigContext";
|
import { Platform } from "ConfigContext";
|
||||||
import ContainerCopyPanel from "Explorer/ContainerCopy/ContainerCopyPanel";
|
|
||||||
import Explorer from "Explorer/Explorer";
|
|
||||||
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
|
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
|
||||||
import { SidebarContainer } from "Explorer/Sidebar";
|
import { SidebarContainer } from "Explorer/Sidebar";
|
||||||
import { KeyboardShortcutRoot } from "KeyboardShortcuts";
|
import { KeyboardShortcutRoot } from "KeyboardShortcuts";
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import "allotment/dist/style.css";
|
import "allotment/dist/style.css";
|
||||||
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
|
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
|
||||||
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
|
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
|
||||||
@@ -86,12 +83,22 @@ const App: React.FunctionComponent = () => {
|
|||||||
return (
|
return (
|
||||||
<KeyboardShortcutRoot>
|
<KeyboardShortcutRoot>
|
||||||
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
|
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
|
||||||
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? (
|
<div id="divExplorer" className="flexContainer hideOverflows">
|
||||||
<ContainerCopyPanel container={explorer} />
|
<div id="freeTierTeachingBubble"> </div>
|
||||||
) : (
|
{/* Main Command Bar - Start */}
|
||||||
<DivExplorer explorer={explorer} />
|
<CommandBar container={explorer} />
|
||||||
)}
|
{/* Collections Tree and Tabs - Begin */}
|
||||||
|
<SidebarContainer explorer={explorer} />
|
||||||
|
{/* Collections Tree and Tabs - End */}
|
||||||
|
<div
|
||||||
|
className="dataExplorerErrorConsoleContainer"
|
||||||
|
role="contentinfo"
|
||||||
|
aria-label="Notification console"
|
||||||
|
id="explorerNotificationConsole"
|
||||||
|
>
|
||||||
|
<NotificationConsole />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<SidePanel />
|
<SidePanel />
|
||||||
<Dialog />
|
<Dialog />
|
||||||
{<QuickstartCarousel isOpen={isCarouselOpen} />}
|
{<QuickstartCarousel isOpen={isCarouselOpen} />}
|
||||||
@@ -106,27 +113,6 @@ const App: React.FunctionComponent = () => {
|
|||||||
const mainElement = document.getElementById("Main");
|
const mainElement = document.getElementById("Main");
|
||||||
ReactDOM.render(<App />, mainElement);
|
ReactDOM.render(<App />, mainElement);
|
||||||
|
|
||||||
function DivExplorer({ explorer }: { explorer: Explorer }): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div id="divExplorer" className="flexContainer hideOverflows">
|
|
||||||
<div id="freeTierTeachingBubble"> </div>
|
|
||||||
{/* Main Command Bar - Start */}
|
|
||||||
<CommandBar container={explorer} />
|
|
||||||
{/* Collections Tree and Tabs - Begin */}
|
|
||||||
<SidebarContainer explorer={explorer} />
|
|
||||||
{/* Collections Tree and Tabs - End */}
|
|
||||||
<div
|
|
||||||
className="dataExplorerErrorConsoleContainer"
|
|
||||||
role="contentinfo"
|
|
||||||
aria-label="Notification console"
|
|
||||||
id="explorerNotificationConsole"
|
|
||||||
>
|
|
||||||
<NotificationConsole />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingExplorer(): JSX.Element {
|
function LoadingExplorer(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="splashLoaderContainer">
|
<div className="splashLoaderContainer">
|
||||||
|
|||||||
13
src/NotebookViewer/notebookViewer.html
Normal file
13
src/NotebookViewer/notebookViewer.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
@@ -25,6 +25,7 @@ export type Features = {
|
|||||||
readonly notebookServerUrl?: string;
|
readonly notebookServerUrl?: string;
|
||||||
readonly sandboxNotebookOutputs: boolean;
|
readonly sandboxNotebookOutputs: boolean;
|
||||||
readonly selfServeType?: string;
|
readonly selfServeType?: string;
|
||||||
|
readonly pr?: string;
|
||||||
readonly showMinRUSurvey: boolean;
|
readonly showMinRUSurvey: boolean;
|
||||||
readonly ttl90Days: boolean;
|
readonly ttl90Days: boolean;
|
||||||
readonly mongoProxyEndpoint?: string;
|
readonly mongoProxyEndpoint?: string;
|
||||||
@@ -38,7 +39,6 @@ export type Features = {
|
|||||||
readonly copilotChatFixedMonacoEditorHeight: boolean;
|
readonly copilotChatFixedMonacoEditorHeight: boolean;
|
||||||
readonly enablePriorityBasedExecution: boolean;
|
readonly enablePriorityBasedExecution: boolean;
|
||||||
readonly disableConnectionStringLogin: boolean;
|
readonly disableConnectionStringLogin: boolean;
|
||||||
readonly enableContainerCopy: boolean;
|
|
||||||
readonly enableCloudShell: boolean;
|
readonly enableCloudShell: boolean;
|
||||||
|
|
||||||
// can be set via both flight and feature flag
|
// can be set via both flight and feature flag
|
||||||
@@ -95,6 +95,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
|||||||
notebookServerUrl: get("notebookserverurl"),
|
notebookServerUrl: get("notebookserverurl"),
|
||||||
sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"),
|
sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"),
|
||||||
selfServeType: get("selfservetype"),
|
selfServeType: get("selfservetype"),
|
||||||
|
pr: get("pr"),
|
||||||
showMinRUSurvey: "true" === get("showminrusurvey"),
|
showMinRUSurvey: "true" === get("showminrusurvey"),
|
||||||
ttl90Days: "true" === get("ttl90days"),
|
ttl90Days: "true" === get("ttl90days"),
|
||||||
autoscaleDefault: "true" === get("autoscaledefault"),
|
autoscaleDefault: "true" === get("autoscaledefault"),
|
||||||
@@ -110,7 +111,6 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
|||||||
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
|
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
|
||||||
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
|
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
|
||||||
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
|
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
|
||||||
enableContainerCopy: "true" === get("enablecontainercopy"),
|
|
||||||
enableCloudShell: true,
|
enableCloudShell: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ function updateUserContext(newContext: Partial<UserContext>): void {
|
|||||||
Object.assign(userContext, newContext);
|
Object.assign(userContext, newContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apiType(account: DatabaseAccount | undefined): ApiType {
|
function apiType(account: DatabaseAccount | undefined): ApiType {
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return "SQL";
|
return "SQL";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ describe("AuthorizationUtils", () => {
|
|||||||
const setAadDataPlane = (enabled: boolean) => {
|
const setAadDataPlane = (enabled: boolean) => {
|
||||||
updateUserContext({
|
updateUserContext({
|
||||||
features: {
|
features: {
|
||||||
enableContainerCopy: false,
|
|
||||||
enableAadDataPlane: enabled,
|
enableAadDataPlane: enabled,
|
||||||
canExceedMaximumValue: false,
|
canExceedMaximumValue: false,
|
||||||
cosmosdb: false,
|
cosmosdb: false,
|
||||||
|
|||||||
@@ -24,10 +24,3 @@ export const isVectorSearchEnabled = (): boolean => {
|
|||||||
(isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLVectorSearch) || isFabricNative())
|
(isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLVectorSearch) || isFabricNative())
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFullTextSearchPreviewFeaturesEnabled = (): boolean => {
|
|
||||||
return (
|
|
||||||
userContext.apiType === "SQL" &&
|
|
||||||
isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLFullTextSearchPreviewFeatures)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { userContext } from "UserContext";
|
|
||||||
|
|
||||||
export function getCopyJobAuthorizationHeader(token: string = ""): Headers {
|
|
||||||
if (!token && !userContext.authorizationToken) {
|
|
||||||
throw new Error("Authorization token is missing");
|
|
||||||
}
|
|
||||||
const headers = new Headers();
|
|
||||||
const authToken = token ? `Bearer ${token}` : userContext.authorizationToken ?? "";
|
|
||||||
headers.append("Authorization", authToken);
|
|
||||||
headers.append("Content-Type", "application/json");
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { configContext } from "ConfigContext";
|
|
||||||
import { buildArmUrl } from "Utils/arm/armUtils";
|
|
||||||
import { armRequest } from "Utils/arm/request";
|
|
||||||
import { getCopyJobAuthorizationHeader } from "../CopyJobAuthUtils";
|
|
||||||
|
|
||||||
export type FetchAccountDetailsParams = {
|
|
||||||
subscriptionId: string;
|
|
||||||
resourceGroupName: string;
|
|
||||||
accountName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleAssignmentPropertiesType = {
|
|
||||||
roleDefinitionId: string;
|
|
||||||
principalId: string;
|
|
||||||
scope: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleAssignmentType = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
properties: RoleAssignmentPropertiesType;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoleDefinitionDataActions = {
|
|
||||||
dataActions: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleDefinitionType = {
|
|
||||||
assignableScopes: string[];
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
permissions: RoleDefinitionDataActions[];
|
|
||||||
resourceGroup: string;
|
|
||||||
roleName: string;
|
|
||||||
type: string;
|
|
||||||
typePropertiesType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiVersion = "2025-04-15";
|
|
||||||
|
|
||||||
const handleResponse = async (response: Response, context: string) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => "");
|
|
||||||
throw new Error(`Failed to fetch ${context}. Status: ${response.status}. ${body || ""}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchRoleAssignments = async (
|
|
||||||
subscriptionId: string,
|
|
||||||
resourceGroupName: string,
|
|
||||||
accountName: string,
|
|
||||||
principalId: string,
|
|
||||||
): Promise<RoleAssignmentType[]> => {
|
|
||||||
const uri = buildArmUrl(
|
|
||||||
`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments`,
|
|
||||||
apiVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await fetch(uri, { method: "GET", headers: getCopyJobAuthorizationHeader() });
|
|
||||||
const data = await handleResponse(response, "role assignments");
|
|
||||||
|
|
||||||
return (data.value || []).filter(
|
|
||||||
(assignment: RoleAssignmentType) => assignment?.properties?.principalId === principalId,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchRoleDefinitions = async (roleAssignments: RoleAssignmentType[]): Promise<RoleDefinitionType[]> => {
|
|
||||||
const roleDefinitionIds = roleAssignments.map((assignment) => assignment.properties.roleDefinitionId);
|
|
||||||
const uniqueRoleDefinitionIds = Array.from(new Set(roleDefinitionIds));
|
|
||||||
|
|
||||||
const headers = getCopyJobAuthorizationHeader();
|
|
||||||
const roleDefinitionUris = uniqueRoleDefinitionIds.map((id) => buildArmUrl(id, apiVersion));
|
|
||||||
|
|
||||||
const promises = roleDefinitionUris.map((url) => fetch(url, { method: "GET", headers }));
|
|
||||||
const responses = await Promise.all(promises);
|
|
||||||
|
|
||||||
const roleDefinitions = await Promise.all(
|
|
||||||
responses.map((res, i) => handleResponse(res, `role definition ${uniqueRoleDefinitionIds[i]}`)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return roleDefinitions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assignRole = async (
|
|
||||||
subscriptionId: string,
|
|
||||||
resourceGroupName: string,
|
|
||||||
accountName: string,
|
|
||||||
principalId: string,
|
|
||||||
): Promise<RoleAssignmentType | null> => {
|
|
||||||
if (!principalId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const accountScope = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`;
|
|
||||||
const roleDefinitionId = `${accountScope}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000001`;
|
|
||||||
const roleAssignmentName = crypto.randomUUID();
|
|
||||||
const path = `${accountScope}/sqlRoleAssignments/${roleAssignmentName}`;
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
properties: {
|
|
||||||
roleDefinitionId,
|
|
||||||
scope: `${accountScope}/`,
|
|
||||||
principalId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const response: RoleAssignmentType = await armRequest({
|
|
||||||
host: configContext.ARM_ENDPOINT,
|
|
||||||
path,
|
|
||||||
method: "PUT",
|
|
||||||
apiVersion,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { configContext } from "ConfigContext";
|
|
||||||
|
|
||||||
const getArmBaseUrl = (): string => {
|
|
||||||
const base = configContext.ARM_ENDPOINT;
|
|
||||||
return base.endsWith("/") ? base.slice(0, -1) : base;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildArmUrl = (path: string, apiVersion: string): string => {
|
|
||||||
if (!path || !apiVersion) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return `${getArmBaseUrl()}${path}?api-version=${apiVersion}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { buildArmUrl, getArmBaseUrl };
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user