mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-23 10:51:30 +00:00
Compare commits
13 Commits
documentdb
...
hotfix/110
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40db06a7a4 | ||
|
|
ff1eb6a78e | ||
|
|
31ec3c08bc | ||
|
|
abf4b3bd0f | ||
|
|
d0d615a85a | ||
|
|
2996120235 | ||
|
|
3cd6d5a65d | ||
|
|
d924824536 | ||
|
|
cd27814fad | ||
|
|
909957a9a1 | ||
|
|
569e5ed1fc | ||
|
|
a5c3e6bea0 | ||
|
|
76e63818d3 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -198,6 +198,18 @@ jobs:
|
||||
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
|
||||
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
|
||||
1
images/AzureOpenAi.svg
Normal file
1
images/AzureOpenAi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="uuid-adbdae8e-5a41-46d1-8c18-aa73cdbfee32" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><path d="m0,2.7v12.6c0,1.491,1.209,2.7,2.7,2.7h12.6c1.491,0,2.7-1.209,2.7-2.7V2.7c0-1.491-1.209-2.7-2.7-2.7H2.7C1.209,0,0,1.209,0,2.7ZM10.8,0v3.6c0,3.976,3.224,7.2,7.2,7.2h-3.6c-3.976,0-7.199,3.222-7.2,7.198v-3.598c0-3.976-3.224-7.2-7.2-7.2h3.6c3.976,0,7.2-3.224,7.2-7.2Z" fill="#000000" stroke-width="0" /></svg>
|
||||
|
After Width: | Height: | Size: 443 B |
3
images/github-black-and-white.svg
Normal file
3
images/github-black-and-white.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 0C8.73438 0 9.44271 0.0960961 10.125 0.288288C10.8073 0.48048 11.4427 0.758091 12.0312 1.12112C12.6198 1.48415 13.1589 1.91124 13.6484 2.4024C14.138 2.89356 14.5573 3.44611 14.9062 4.06006C15.2552 4.67401 15.5234 5.32799 15.7109 6.02202C15.8984 6.71605 15.9948 7.44211 16 8.2002C16 9.08108 15.8698 9.92993 15.6094 10.7467C15.349 11.5636 14.9766 12.3136 14.4922 12.997C14.0078 13.6803 13.4323 14.2783 12.7656 14.7908C12.099 15.3033 11.3542 15.701 10.5312 15.984C10.5156 15.9893 10.4922 15.992 10.4609 15.992C10.4297 15.992 10.4062 15.9947 10.3906 16C10.2656 16 10.1667 15.9626 10.0938 15.8879C10.0208 15.8131 9.98438 15.7144 9.98438 15.5916V14.4705C9.98438 14.1021 9.98698 13.7257 9.99219 13.3413C9.99219 13.0691 9.95312 12.7941 9.875 12.5165C9.79688 12.2389 9.65625 12.0067 9.45312 11.8198C10.0573 11.7504 10.5859 11.625 11.0391 11.4434C11.4922 11.2619 11.8724 11.0057 12.1797 10.6747C12.487 10.3437 12.7161 9.94328 12.8672 9.47347C13.0182 9.00367 13.0964 8.43777 13.1016 7.77578C13.1016 7.35936 13.0339 6.96697 12.8984 6.5986C12.763 6.23023 12.5573 5.88856 12.2812 5.57357C12.3385 5.42409 12.3802 5.26927 12.4062 5.10911C12.4323 4.94895 12.4453 4.78879 12.4453 4.62863C12.4453 4.42042 12.4245 4.21488 12.3828 4.01201C12.3411 3.80914 12.2812 3.60627 12.2031 3.4034C12.1771 3.39273 12.1484 3.38739 12.1172 3.38739C12.0859 3.38739 12.0573 3.38739 12.0312 3.38739C11.8646 3.38739 11.6901 3.41408 11.5078 3.46747C11.3255 3.52085 11.1458 3.59026 10.9688 3.67568C10.7917 3.76109 10.6172 3.85452 10.4453 3.95596C10.2734 4.05739 10.125 4.15349 10 4.24424C9.34896 4.05739 8.68229 3.96396 8 3.96396C7.31771 3.96396 6.65104 4.05739 6 4.24424C5.86979 4.15349 5.72135 4.05739 5.55469 3.95596C5.38802 3.85452 5.21615 3.76376 5.03906 3.68368C4.86198 3.6036 4.67969 3.5342 4.49219 3.47548C4.30469 3.41675 4.13021 3.38739 3.96875 3.38739H3.88281C3.85156 3.38739 3.82292 3.39273 3.79688 3.4034C3.72396 3.60093 3.66667 3.80113 3.625 4.004C3.58333 4.20687 3.5599 4.41508 3.55469 4.62863C3.55469 4.78879 3.56771 4.94895 3.59375 5.10911C3.61979 5.26927 3.66146 5.42409 3.71875 5.57357C3.44271 5.88322 3.23698 6.22222 3.10156 6.59059C2.96615 6.95896 2.89844 7.35402 2.89844 7.77578C2.89844 8.42709 2.97396 8.99032 3.125 9.46547C3.27604 9.94061 3.50521 10.341 3.8125 10.6667C4.11979 10.9923 4.5 11.2513 4.95312 11.4434C5.40625 11.6356 5.9349 11.7638 6.53906 11.8278C6.38802 11.9666 6.27344 12.1321 6.19531 12.3243C6.11719 12.5165 6.0625 12.7167 6.03125 12.9249C5.89062 12.9943 5.74219 13.0477 5.58594 13.0851C5.42969 13.1225 5.27344 13.1411 5.11719 13.1411C4.78385 13.1411 4.50781 13.0611 4.28906 12.9009C4.07031 12.7407 3.875 12.5219 3.70312 12.2442C3.64062 12.1428 3.5651 12.0414 3.47656 11.9399C3.38802 11.8385 3.29167 11.7477 3.1875 11.6677C3.08333 11.5876 2.97135 11.5235 2.85156 11.4755C2.73177 11.4274 2.60677 11.4007 2.47656 11.3954H2.38281C2.34115 11.3954 2.30208 11.4034 2.26562 11.4194C2.22917 11.4354 2.19271 11.4515 2.15625 11.4675C2.11979 11.4835 2.10417 11.5102 2.10938 11.5475C2.10938 11.6116 2.14583 11.673 2.21875 11.7317C2.29167 11.7905 2.35156 11.8385 2.39844 11.8759L2.42188 11.8919C2.53646 11.9826 2.63542 12.0681 2.71875 12.1481C2.80208 12.2282 2.88021 12.3163 2.95312 12.4124C3.02604 12.5085 3.08594 12.6099 3.13281 12.7167C3.17969 12.8235 3.23958 12.9489 3.3125 13.0931C3.48958 13.5095 3.73698 13.8111 4.05469 13.998C4.3724 14.1849 4.75521 14.2809 5.20312 14.2863C5.33854 14.2863 5.47396 14.2783 5.60938 14.2623C5.74479 14.2462 5.88021 14.2222 6.01562 14.1902V15.5836C6.01562 15.7117 5.97917 15.8131 5.90625 15.8879C5.83333 15.9626 5.73177 16 5.60156 16H5.53906C5.51302 16 5.48958 15.9947 5.46875 15.984C4.65104 15.7117 3.90625 15.3193 3.23438 14.8068C2.5625 14.2943 1.98698 13.6937 1.50781 13.005C1.02865 12.3163 0.658854 11.5636 0.398438 10.7467C0.138021 9.92993 0.00520833 9.08108 0 8.2002C0 7.44745 0.09375 6.72139 0.28125 6.02202C0.46875 5.32266 0.739583 4.67134 1.09375 4.06807C1.44792 3.4648 1.86458 2.91225 2.34375 2.41041C2.82292 1.90858 3.36198 1.47881 3.96094 1.12112C4.5599 0.76343 5.19792 0.488488 5.875 0.296296C6.55208 0.104104 7.26042 0.00533867 8 0Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -1,6 +1,6 @@
|
||||
[defaults]
|
||||
group = dataexplorer-preview
|
||||
sku = P1V2
|
||||
sku = P1v2
|
||||
appserviceplan = dataexplorer-preview
|
||||
location = westus2
|
||||
web = dataexplorer-preview
|
||||
|
||||
36205
preview/package-lock.json
generated
36205
preview/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,16 +4,18 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:18-lts\" --sku P1V2",
|
||||
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:20-lts\" --sku P1V2",
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Microsoft Corporation",
|
||||
"dependencies": {
|
||||
"express": "^4.17.1",
|
||||
"body-parser": "^1.20.3",
|
||||
"express": "^4.21.2",
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"node": "^18.20.6",
|
||||
"node-fetch": "^2.6.1"
|
||||
"node": "^20.19.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"path-to-regexp": "^0.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
11246
sampleData/fabricSampleData.json
Normal file
11246
sampleData/fabricSampleData.json
Normal file
File diff suppressed because it is too large
Load Diff
288086
sampleData/fabricSampleDataVectors.json
Normal file
288086
sampleData/fabricSampleDataVectors.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,7 @@ export class CapabilityNames {
|
||||
public static readonly EnableMongo: string = "EnableMongo";
|
||||
public static readonly EnableServerless: string = "EnableServerless";
|
||||
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
|
||||
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
|
||||
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
|
||||
}
|
||||
|
||||
export enum CapacityMode {
|
||||
@@ -138,6 +138,14 @@ export enum MongoBackendEndpointType {
|
||||
remote,
|
||||
}
|
||||
|
||||
export class AadScopeEndpoints {
|
||||
public static readonly Development: string = "https://cosmos.azure.com";
|
||||
public static readonly MPAC: string = "https://cosmos.azure.com";
|
||||
public static readonly Prod: string = "https://cosmos.azure.com";
|
||||
public static readonly Fairfax: string = "https://cosmos.azure.us";
|
||||
public static readonly Mooncake: string = "https://cosmos.azure.cn";
|
||||
}
|
||||
|
||||
export class PortalBackendEndpoints {
|
||||
public static readonly Development: string = "https://localhost:7235";
|
||||
public static readonly Mpac: string = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
||||
@@ -255,6 +263,7 @@ export class HttpHeaders {
|
||||
public static activityId: string = "x-ms-activity-id";
|
||||
public static apiType: string = "x-ms-cosmos-apitype";
|
||||
public static authorization: string = "authorization";
|
||||
public static entraIdToken: string = "x-ms-entraid-token";
|
||||
public static collectionIndexTransformationProgress: string =
|
||||
"x-ms-documentdb-collection-index-transformation-progress";
|
||||
public static continuation: string = "x-ms-continuation";
|
||||
|
||||
@@ -28,3 +28,39 @@ describe("Environment Utility Test", () => {
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
|
||||
});
|
||||
});
|
||||
describe("normalizeArmEndpoint", () => {
|
||||
it("should append '/' if not present", () => {
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("should return the same uri if '/' is present at the end", () => {
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com/")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironment", () => {
|
||||
it("should return Prod environment", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Prod);
|
||||
});
|
||||
|
||||
it("should return Fairfax environment", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Fairfax,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Fairfax);
|
||||
});
|
||||
|
||||
it("should return Mooncake environment", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mooncake,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mooncake);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { AadScopeEndpoints, PortalBackendEndpoints } from "Common/Constants";
|
||||
import * as Logger from "Common/Logger";
|
||||
import { configContext } from "ConfigContext";
|
||||
|
||||
export function normalizeArmEndpoint(uri: string): string {
|
||||
@@ -27,3 +28,17 @@ export const getEnvironment = (): Environment => {
|
||||
|
||||
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
|
||||
};
|
||||
|
||||
export const getEnvironmentScopeEndpoint = (): string => {
|
||||
const environment = getEnvironment();
|
||||
const endpoint = AadScopeEndpoints[environment];
|
||||
if (!endpoint) {
|
||||
throw new Error("Cannot determine AAD scope endpoint");
|
||||
}
|
||||
const hrefEndpoint = new URL(endpoint).href.replace(/\/+$/, "/.default");
|
||||
Logger.logInfo(
|
||||
`Using AAD scope endpoint: ${hrefEndpoint}, Environment: ${environment}`,
|
||||
"EnvironmentUtility/getEnvironmentScopeEndpoint",
|
||||
);
|
||||
return hrefEndpoint;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||
import { Collection } from "../Contracts/ViewModels";
|
||||
import DocumentId from "../Explorer/Tree/DocumentId";
|
||||
import { userContext } from "../UserContext";
|
||||
import { isDataplaneRbacEnabledForProxyApi } from "../Utils/AuthorizationUtils";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes } from "./Constants";
|
||||
import { MinimalQueryIterator } from "./IteratorUtilities";
|
||||
@@ -22,7 +23,13 @@ function authHeaders() {
|
||||
if (userContext.authType === AuthType.EncryptedToken) {
|
||||
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
|
||||
} else {
|
||||
return { [HttpHeaders.authorization]: userContext.authorizationToken };
|
||||
const headers: { [key: string]: string } = {
|
||||
[HttpHeaders.authorization]: userContext.authorizationToken,
|
||||
};
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
headers[HttpHeaders.entraIdToken] = userContext.aadToken;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ContainerRequest, ContainerResponse, DatabaseRequest, DatabaseResponse, RequestOptions } from "@azure/cosmos";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
@@ -43,6 +45,14 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
|
||||
}
|
||||
|
||||
logConsoleInfo(`Successfully created container ${params.collectionId}`);
|
||||
|
||||
if (isFabricNative()) {
|
||||
sendMessage({
|
||||
type: FabricMessageTypes.ContainerUpdated,
|
||||
params: { updateType: "created" },
|
||||
});
|
||||
}
|
||||
|
||||
return collection;
|
||||
} catch (error) {
|
||||
handleError(error, "CreateCollection", `Error while creating container ${params.collectionId}`);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { userContext } from "../../UserContext";
|
||||
@@ -19,6 +21,11 @@ export async function deleteCollection(databaseId: string, collectionId: string)
|
||||
await client().database(databaseId).container(collectionId).delete();
|
||||
}
|
||||
logConsoleInfo(`Successfully deleted container ${collectionId}`);
|
||||
|
||||
sendMessage({
|
||||
type: FabricMessageTypes.ContainerUpdated,
|
||||
params: { updateType: "deleted" },
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteCollection", `Error while deleting container ${collectionId}`);
|
||||
throw error;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Item, RequestOptions } from "@azure/cosmos";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { CollectionBase } from "../../Contracts/ViewModels";
|
||||
import DocumentId from "../../Explorer/Tree/DocumentId";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
@@ -23,10 +24,17 @@ export const updateDocument = async (
|
||||
[HttpHeaders.partitionKey]: documentId.partitionKeyValue,
|
||||
}
|
||||
: {};
|
||||
|
||||
// If user has chosen to ignore partition key on update, pass null instead of actual partition key value
|
||||
const ignorePartitionKeyOnDocumentUpdateFlag = LocalStorageUtility.getEntryBoolean(
|
||||
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
|
||||
);
|
||||
const partitionKey = ignorePartitionKeyOnDocumentUpdateFlag ? undefined : getPartitionKeyValue(documentId);
|
||||
|
||||
const response = await client()
|
||||
.database(collection.databaseId)
|
||||
.container(collection.id())
|
||||
.item(documentId.id(), getPartitionKeyValue(documentId))
|
||||
.item(documentId.id(), partitionKey)
|
||||
.replace(newDocument, options);
|
||||
|
||||
logConsoleInfo(`Successfully updated ${entityName} ${documentId.id()}`);
|
||||
|
||||
@@ -110,11 +110,30 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints || defaultAllowedAadEndpoints)) {
|
||||
if (newContext.allowedAadEndpoints) {
|
||||
Object.assign(configContext, { allowedAadEndpoints: newContext.allowedAadEndpoints });
|
||||
}
|
||||
if (newContext.allowedArmEndpoints) {
|
||||
Object.assign(configContext, { allowedArmEndpoints: newContext.allowedArmEndpoints });
|
||||
}
|
||||
if (newContext.allowedGraphEndpoints) {
|
||||
Object.assign(configContext, { allowedGraphEndpoints: newContext.allowedGraphEndpoints });
|
||||
}
|
||||
if (newContext.allowedBackendEndpoints) {
|
||||
Object.assign(configContext, { allowedBackendEndpoints: newContext.allowedBackendEndpoints });
|
||||
}
|
||||
if (newContext.allowedMongoProxyEndpoints) {
|
||||
Object.assign(configContext, { allowedMongoProxyEndpoints: newContext.allowedMongoProxyEndpoints });
|
||||
}
|
||||
if (newContext.allowedCassandraProxyEndpoints) {
|
||||
Object.assign(configContext, { allowedCassandraProxyEndpoints: newContext.allowedCassandraProxyEndpoints });
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints)) {
|
||||
delete newContext.AAD_ENDPOINT;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints || defaultAllowedArmEndpoints)) {
|
||||
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints)) {
|
||||
delete newContext.ARM_ENDPOINT;
|
||||
}
|
||||
|
||||
@@ -122,9 +141,7 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
delete newContext.EMULATOR_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints || defaultAllowedGraphEndpoints)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints)) {
|
||||
delete newContext.GRAPH_ENDPOINT;
|
||||
}
|
||||
|
||||
@@ -132,30 +149,15 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
delete newContext.ARCADIA_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.PORTAL_BACKEND_ENDPOINT,
|
||||
configContext.allowedBackendEndpoints || defaultAllowedBackendEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.PORTAL_BACKEND_ENDPOINT, configContext.allowedBackendEndpoints)) {
|
||||
delete newContext.PORTAL_BACKEND_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.MONGO_PROXY_ENDPOINT,
|
||||
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, configContext.allowedMongoProxyEndpoints)) {
|
||||
delete newContext.MONGO_PROXY_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.CASSANDRA_PROXY_ENDPOINT,
|
||||
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, configContext.allowedCassandraProxyEndpoints)) {
|
||||
delete newContext.CASSANDRA_PROXY_ENDPOINT;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FabricMessageTypes } from "./FabricMessageTypes";
|
||||
import { MessageTypes } from "./MessageTypes";
|
||||
|
||||
// This is the current version of these messages
|
||||
export const DATA_EXPLORER_RPC_VERSION = "3";
|
||||
@@ -19,9 +20,32 @@ export type DataExploreMessageV3 =
|
||||
type: FabricMessageTypes.GetAllResourceTokens;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.GetAccessToken;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: MessageTypes.TelemetryInfo;
|
||||
data: {
|
||||
action: string;
|
||||
actionModifier: string;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.OpenSettings;
|
||||
settingsId: string;
|
||||
params: [{ settingsId?: "About" | "Connection" }];
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.RestoreContainer;
|
||||
params: [];
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.ContainerUpdated;
|
||||
params: {
|
||||
updateType: "created" | "deleted" | "settings";
|
||||
};
|
||||
};
|
||||
export interface GetCosmosTokenMessageOptions {
|
||||
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
|
||||
@@ -7,6 +7,8 @@ export enum FabricMessageTypes {
|
||||
GetAccessToken = "GetAccessToken",
|
||||
Ready = "Ready",
|
||||
OpenSettings = "OpenSettings",
|
||||
RestoreContainer = "RestoreContainer",
|
||||
ContainerUpdated = "ContainerUpdated",
|
||||
}
|
||||
|
||||
export interface AuthorizationToken {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
|
||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import * as React from "react";
|
||||
import { isFullTextSearchPreviewFeaturesEnabled } from "Utils/CapabilityUtils";
|
||||
|
||||
export interface FullTextPoliciesComponentProps {
|
||||
fullTextPolicy: FullTextPolicy;
|
||||
@@ -22,6 +23,7 @@ export interface FullTextPoliciesComponentProps {
|
||||
) => void;
|
||||
discardChanges?: boolean;
|
||||
onChangesDiscarded?: () => void;
|
||||
englishOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface FullTextPolicyData {
|
||||
@@ -66,6 +68,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
onFullTextPathChange,
|
||||
discardChanges,
|
||||
onChangesDiscarded,
|
||||
englishOnly,
|
||||
}): JSX.Element => {
|
||||
const getFullTextPathError = (path: string, index?: number): string => {
|
||||
let error = "";
|
||||
@@ -87,6 +90,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
if (!fullTextPolicy) {
|
||||
fullTextPolicy = { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] };
|
||||
}
|
||||
|
||||
return fullTextPolicy.fullTextPaths.map((fullTextPath: FullTextPath) => ({
|
||||
...fullTextPath,
|
||||
pathError: getFullTextPathError(fullTextPath.path),
|
||||
@@ -166,7 +170,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
<Dropdown
|
||||
required={true}
|
||||
styles={dropdownStyles}
|
||||
options={getFullTextLanguageOptions()}
|
||||
options={getFullTextLanguageOptions(englishOnly)}
|
||||
selectedKey={defaultLanguage}
|
||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
||||
setDefaultLanguage(option.key as never)
|
||||
@@ -211,7 +215,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
<Dropdown
|
||||
required={true}
|
||||
styles={dropdownStyles}
|
||||
options={getFullTextLanguageOptions()}
|
||||
options={getFullTextLanguageOptions(englishOnly)}
|
||||
selectedKey={fullTextPolicy.language}
|
||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
||||
onFullTextPathPolicyChange(index, option)
|
||||
@@ -229,11 +233,30 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
);
|
||||
};
|
||||
|
||||
export const getFullTextLanguageOptions = (): IDropdownOption[] => {
|
||||
return [
|
||||
export const getFullTextLanguageOptions = (englishOnly?: boolean): IDropdownOption[] => {
|
||||
const multiLanguageSupportEnabled: boolean = isFullTextSearchPreviewFeaturesEnabled() && !englishOnly;
|
||||
const fullTextLanguageOptions: IDropdownOption[] = [
|
||||
{
|
||||
key: "en-US",
|
||||
text: "English (US)",
|
||||
},
|
||||
...(multiLanguageSupportEnabled
|
||||
? [
|
||||
{
|
||||
key: "fr-FR",
|
||||
text: "French",
|
||||
},
|
||||
{
|
||||
key: "de-DE",
|
||||
text: "German",
|
||||
},
|
||||
{
|
||||
key: "es-ES",
|
||||
text: "Spanish",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return fullTextLanguageOptions;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { IndexingPolicy } from "@azure/cosmos";
|
||||
import { act } from "@testing-library/react";
|
||||
import { AuthType } from "AuthType";
|
||||
import { shallow } from "enzyme";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import ko from "knockout";
|
||||
import React from "react";
|
||||
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
|
||||
@@ -287,3 +290,47 @@ describe("SettingsComponent", () => {
|
||||
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsComponent - indexing policy subscription", () => {
|
||||
const baseProps: SettingsComponentProps = {
|
||||
settingsTab: new CollectionSettingsTabV2({
|
||||
collection: collection,
|
||||
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
node: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
|
||||
const containerId = collection.id();
|
||||
const mockIndexingPolicy: IndexingPolicy = {
|
||||
automatic: false,
|
||||
indexingMode: "lazy",
|
||||
includedPaths: [{ path: "/foo/*" }],
|
||||
excludedPaths: [{ path: "/bar/*" }],
|
||||
compositeIndexes: [],
|
||||
spatialIndexes: [],
|
||||
vectorIndexes: [],
|
||||
fullTextIndexes: [],
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const instance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
await act(async () => {
|
||||
useIndexingPolicyStore.setState({
|
||||
indexingPolicies: {
|
||||
[containerId]: mockIndexingPolicy,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
|
||||
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
|
||||
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
|
||||
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { IPivotItemProps, IPivotProps, Pivot, PivotItem } from "@fluentui/react";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import {
|
||||
ComputedPropertiesComponent,
|
||||
ComputedPropertiesComponentProps,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
ThroughputBucketsComponent,
|
||||
ThroughputBucketsComponentProps,
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
@@ -70,7 +73,6 @@ import {
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure,
|
||||
} from "./SettingsUtils";
|
||||
|
||||
interface SettingsV2TabInfo {
|
||||
tab: SettingsV2TabTypes;
|
||||
content: JSX.Element;
|
||||
@@ -173,7 +175,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private totalThroughputUsed: number;
|
||||
private throughputBucketsEnabled: boolean;
|
||||
public mongoDBCollectionResource: MongoDBCollectionResource;
|
||||
|
||||
private unsubscribe: () => void;
|
||||
constructor(props: SettingsComponentProps) {
|
||||
super(props);
|
||||
|
||||
@@ -303,8 +305,19 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}
|
||||
this.unsubscribe = useIndexingPolicyStore.subscribe(
|
||||
() => {
|
||||
this.refreshCollectionData();
|
||||
},
|
||||
(state) => state.indexingPolicies[this.collection.id()],
|
||||
);
|
||||
this.refreshCollectionData();
|
||||
}
|
||||
componentWillUnmount(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
@@ -431,6 +444,15 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
);
|
||||
} finally {
|
||||
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) {
|
||||
sendMessage({
|
||||
type: FabricMessageTypes.ContainerUpdated,
|
||||
params: { updateType: "settings" },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -777,7 +799,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
{ name: "name_of_property", query: "query_to_compute_property" },
|
||||
] as DataModels.ComputedProperties;
|
||||
}
|
||||
|
||||
const throughputBuckets = this.offer?.throughputBuckets;
|
||||
|
||||
return {
|
||||
@@ -929,10 +950,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
startKey,
|
||||
);
|
||||
};
|
||||
private refreshCollectionData = async (): Promise<void> => {
|
||||
const containerId = this.collection.id();
|
||||
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
|
||||
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
|
||||
|
||||
const latestCollection: DataModels.IndexingPolicy = {
|
||||
automatic: rawPolicy?.automatic ?? true,
|
||||
indexingMode: rawPolicy?.indexingMode ?? "consistent",
|
||||
includedPaths: rawPolicy?.includedPaths ?? [],
|
||||
excludedPaths: rawPolicy?.excludedPaths ?? [],
|
||||
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
|
||||
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
|
||||
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
|
||||
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
|
||||
};
|
||||
|
||||
this.collection.rawDataModel.indexingPolicy = latestCollection;
|
||||
this.setState({
|
||||
indexingPolicyContent: latestCollection,
|
||||
indexingPolicyContentBaseline: latestCollection,
|
||||
});
|
||||
};
|
||||
|
||||
private saveCollectionSettings = async (startKey: number): Promise<void> => {
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
if (
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isContainerPolicyDirty ||
|
||||
@@ -1161,7 +1203,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
throughputError: this.state.throughputError,
|
||||
};
|
||||
|
||||
if (!this.isCollectionSettingsTab) {
|
||||
return (
|
||||
<div className="settingsV2MainContainer">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { MessageBar, MessageBarType } from "@fluentui/react";
|
||||
import * as React from "react";
|
||||
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
|
||||
import {
|
||||
mongoIndexTransformationRefreshingMessage,
|
||||
renderMongoIndexTransformationRefreshMessage,
|
||||
} from "../../SettingsRenderUtils";
|
||||
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
|
||||
import { isIndexTransforming } from "../../SettingsUtils";
|
||||
|
||||
export interface IndexingPolicyRefreshComponentProps {
|
||||
|
||||
@@ -72,6 +72,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -164,6 +174,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -238,17 +258,25 @@ exports[`SettingsComponent renders 1`] = `
|
||||
indexingPolicyContent={
|
||||
{
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
}
|
||||
}
|
||||
indexingPolicyContentBaseline={
|
||||
{
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
}
|
||||
}
|
||||
isVectorSearchEnabled={false}
|
||||
@@ -321,6 +349,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -461,6 +499,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
||||
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { userContext } from "UserContext";
|
||||
@@ -30,7 +31,7 @@ export interface CommandBarStore {
|
||||
}
|
||||
|
||||
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
|
||||
contextButtons: [],
|
||||
contextButtons: [] as CommandButtonComponentProps[],
|
||||
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
|
||||
isHidden: false,
|
||||
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
|
||||
@@ -43,6 +44,15 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
|
||||
const backgroundColor = StyleConstants.BaseLight;
|
||||
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
|
||||
|
||||
// Subscribe to the store changes that affect button creation
|
||||
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
|
||||
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
|
||||
|
||||
// Memoize the expensive button creation
|
||||
const staticButtons = React.useMemo(() => {
|
||||
return CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState);
|
||||
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated]);
|
||||
|
||||
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
|
||||
const buttons =
|
||||
userContext.apiType === "Postgres"
|
||||
@@ -62,7 +72,6 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const staticButtons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState);
|
||||
const contextButtons = (buttons || []).concat(
|
||||
CommandBarComponentButtonFactory.createContextCommandBarButtons(container, selectedNodeState),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { KeyboardAction } from "KeyboardShortcuts";
|
||||
import { isDataplaneRbacSupported } from "Utils/APITypeUtils";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import AddSqlQueryIcon from "../../../../images/AddSqlQuery_16x16.svg";
|
||||
import AddStoredProcedureIcon from "../../../../images/AddStoredProcedure.svg";
|
||||
import AddTriggerIcon from "../../../../images/AddTrigger.svg";
|
||||
@@ -68,15 +67,7 @@ export function createStaticCommandBarButtons(
|
||||
}
|
||||
|
||||
if (isDataplaneRbacSupported(userContext.apiType)) {
|
||||
const [loginButtonProps, setLoginButtonProps] = useState<CommandButtonComponentProps | undefined>(undefined);
|
||||
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
|
||||
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
|
||||
|
||||
useEffect(() => {
|
||||
const buttonProps = createLoginForEntraIDButton(container);
|
||||
setLoginButtonProps(buttonProps);
|
||||
}, [dataPlaneRbacEnabled, aadTokenUpdated, container]);
|
||||
|
||||
const loginButtonProps = createLoginForEntraIDButton(container);
|
||||
if (loginButtonProps) {
|
||||
addDivider();
|
||||
buttons.push(loginButtonProps);
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { DEFAULT_FABRIC_NATIVE_CONTAINER_THROUGHPUT, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import React from "react";
|
||||
import { CollectionCreation } from "Shared/Constants";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
@@ -52,7 +52,6 @@ import { getCollectionName } from "Utils/APITypeUtils";
|
||||
import { isCapabilityEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { getUpsellMessage } from "Utils/PricingUtils";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
|
||||
import { CollapsibleSectionComponent } from "../../Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
|
||||
import { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator";
|
||||
@@ -894,6 +893,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
) => {
|
||||
this.setState({ fullTextPolicy, fullTextIndexes, fullTextPolicyValidated });
|
||||
}}
|
||||
// Remove when multi language support on container create issue is fixed
|
||||
englishOnly={true}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -1355,8 +1356,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
// Throughput
|
||||
if (isFabricNative()) {
|
||||
// Fabric Native accounts are always autoscale and have a fixed throughput of 5K
|
||||
autoPilotMaxThroughput = AutoPilotUtils.autoPilotThroughput5K;
|
||||
autoPilotMaxThroughput = DEFAULT_FABRIC_NATIVE_CONTAINER_THROUGHPUT;
|
||||
offerThroughput = undefined;
|
||||
} else if (databaseLevelThroughput) {
|
||||
if (this.state.createNewDatabase) {
|
||||
|
||||
@@ -516,6 +516,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
}
|
||||
>
|
||||
<FullTextPoliciesComponent
|
||||
englishOnly={true}
|
||||
fullTextPolicy={
|
||||
{
|
||||
"defaultLanguage": "en-US",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IToggleStyles,
|
||||
Position,
|
||||
SpinButton,
|
||||
Stack,
|
||||
Toggle,
|
||||
} from "@fluentui/react";
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
||||
@@ -204,6 +205,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
? (LocalStorageUtility.getEntryString(StorageKey.MongoGuidRepresentation) as Constants.MongoGuidRepresentation)
|
||||
: Constants.MongoGuidRepresentation.CSharpLegacy,
|
||||
);
|
||||
const [ignorePartitionKeyOnDocumentUpdate, setIgnorePartitionKeyOnDocumentUpdate] = useState<boolean>(
|
||||
LocalStorageUtility.getEntryBoolean(StorageKey.IgnorePartitionKeyOnDocumentUpdate),
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
@@ -424,6 +428,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
LocalStorageUtility.setEntryString(StorageKey.MongoGuidRepresentation, mongoGuidRepresentation);
|
||||
}
|
||||
|
||||
// Advanced settings
|
||||
LocalStorageUtility.setEntryBoolean(
|
||||
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
|
||||
ignorePartitionKeyOnDocumentUpdate,
|
||||
);
|
||||
|
||||
setIsExecuting(false);
|
||||
logConsoleInfo(
|
||||
`Updated items per page setting to ${LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage)}`,
|
||||
@@ -453,6 +463,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
);
|
||||
}
|
||||
|
||||
logConsoleInfo(
|
||||
`${ignorePartitionKeyOnDocumentUpdate ? "Enabled" : "Disabled"} ignoring partition key on document update`,
|
||||
);
|
||||
|
||||
refreshExplorer && (await explorer.refreshExplorer());
|
||||
closeSidePanel();
|
||||
};
|
||||
@@ -593,6 +607,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
setMongoGuidRepresentation(option.key as Constants.MongoGuidRepresentation);
|
||||
};
|
||||
|
||||
const handleOnIgnorePartitionKeyOnDocumentUpdateChange = (
|
||||
ev: React.MouseEvent<HTMLElement>,
|
||||
checked?: boolean,
|
||||
): void => {
|
||||
setIgnorePartitionKeyOnDocumentUpdate(!!checked);
|
||||
};
|
||||
|
||||
const choiceButtonStyles = {
|
||||
root: {
|
||||
clear: "both",
|
||||
@@ -1137,6 +1158,29 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
<AccordionItem value="15">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Advanced Settings</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 4 }}>
|
||||
<Checkbox
|
||||
styles={{ label: { padding: 0 } }}
|
||||
className="padding"
|
||||
ariaLabel="Ignore partition key on document update"
|
||||
checked={ignorePartitionKeyOnDocumentUpdate}
|
||||
onChange={handleOnIgnorePartitionKeyOnDocumentUpdateChange}
|
||||
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>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
|
||||
@@ -575,6 +575,52 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="15"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Advanced Settings
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 4,
|
||||
}
|
||||
}
|
||||
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>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div
|
||||
className="settingsSection"
|
||||
@@ -838,6 +884,52 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="15"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Advanced Settings
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 4,
|
||||
}
|
||||
}
|
||||
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>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div
|
||||
className="settingsSection"
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { Upload } from "Common/Upload/Upload";
|
||||
import { UploadDetailsRecord } from "Contracts/ViewModels";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import React, { ChangeEvent, FunctionComponent, useState } from "react";
|
||||
import React, { ChangeEvent, FunctionComponent, useReducer, useState } from "react";
|
||||
import { getErrorMessage } from "../../Tables/Utilities";
|
||||
import { useSelectedNode } from "../../useSelectedNode";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
@@ -57,6 +57,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
const [uploadFileData, setUploadFileData] = useState<UploadDetailsRecord[]>([]);
|
||||
const [formError, setFormError] = useState<string>("");
|
||||
const [isExecuting, setIsExecuting] = useState<boolean>();
|
||||
const [reducer, setReducer] = useReducer((x) => x + 1, 1);
|
||||
|
||||
const onSubmit = () => {
|
||||
setFormError("");
|
||||
@@ -75,6 +76,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
(uploadDetails) => {
|
||||
setUploadFileData(uploadDetails.data);
|
||||
setFiles(undefined);
|
||||
setReducer(); // Trigger a re-render to update the UI with new upload details
|
||||
// Emit the upload details to the parent component
|
||||
onUpload && onUpload(uploadDetails.data);
|
||||
},
|
||||
@@ -95,6 +97,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
const props: RightPaneFormProps = {
|
||||
formError,
|
||||
isExecuting: isExecuting,
|
||||
isSubmitButtonDisabled: !files || files.length === 0,
|
||||
submitButtonText: "Upload",
|
||||
onSubmit,
|
||||
};
|
||||
@@ -192,6 +195,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
<RightPaneForm {...props}>
|
||||
<div className="paneMainContent">
|
||||
<Upload
|
||||
key={reducer} // Force re-render on state change
|
||||
label="Select JSON Files"
|
||||
onUpload={updateSelectedFiles}
|
||||
accept="application/json"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
exports[`Upload Items Pane should render Default properly 1`] = `
|
||||
<RightPaneForm
|
||||
formError=""
|
||||
isSubmitButtonDisabled={true}
|
||||
onSubmit={[Function]}
|
||||
submitButtonText="Upload"
|
||||
>
|
||||
@@ -11,6 +12,7 @@ exports[`Upload Items Pane should render Default properly 1`] = `
|
||||
>
|
||||
<Upload
|
||||
accept="application/json"
|
||||
key="1"
|
||||
label="Select JSON Files"
|
||||
multiple={true}
|
||||
onUpload={[Function]}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
*/
|
||||
import { Link, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { DocumentAddRegular, LinkMultipleRegular, OpenRegular } from "@fluentui/react-icons";
|
||||
import { SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
|
||||
import { SampleDataConfiguration, SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
|
||||
import { SampleDataFile } from "Explorer/SplashScreen/SampleUtil";
|
||||
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||
import { isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil";
|
||||
import * as React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import AzureOpenAiIcon from "../../../images/AzureOpenAi.svg";
|
||||
import CosmosDbBlackIcon from "../../../images/CosmosDB_black.svg";
|
||||
import GithubIcon from "../../../images/github-black-and-white.svg";
|
||||
import Explorer from "../Explorer";
|
||||
|
||||
export interface SplashScreenProps {
|
||||
@@ -26,11 +29,11 @@ const useStyles = makeStyles({
|
||||
fontWeight: "bold",
|
||||
},
|
||||
buttonsContainer: {
|
||||
width: "584px",
|
||||
width: "760px",
|
||||
margin: "auto",
|
||||
display: "grid",
|
||||
padding: "16px",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: "10px",
|
||||
gridAutoRows: "minmax(184px, auto)",
|
||||
},
|
||||
@@ -53,6 +56,15 @@ const useStyles = makeStyles({
|
||||
},
|
||||
},
|
||||
three: {
|
||||
gridColumn: "4",
|
||||
gridRow: "1",
|
||||
"& img": {
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
margin: "auto",
|
||||
},
|
||||
},
|
||||
four: {
|
||||
gridColumn: "3",
|
||||
gridRow: "2",
|
||||
"& svg": {
|
||||
@@ -61,6 +73,15 @@ const useStyles = makeStyles({
|
||||
margin: "auto",
|
||||
},
|
||||
},
|
||||
five: {
|
||||
gridColumn: "4",
|
||||
gridRow: "2",
|
||||
"& img": {
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
margin: "auto",
|
||||
},
|
||||
},
|
||||
single: {
|
||||
gridColumn: "1 / 4",
|
||||
gridRow: "1 / 3",
|
||||
@@ -132,6 +153,8 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
|
||||
export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScreenProps) => {
|
||||
const styles = useStyles();
|
||||
const [openSampleDataImportDialog, setOpenSampleDataImportDialog] = React.useState(false);
|
||||
const [selectedSampleDataConfiguration, setSelectedSampleDataConfiguration] =
|
||||
React.useState<SampleDataConfiguration>(undefined);
|
||||
|
||||
const getSplashScreenButtons = (): JSX.Element => {
|
||||
const buttons: FabricHomeScreenButtonProps[] = [
|
||||
@@ -145,10 +168,30 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Sample data",
|
||||
description: "Automatically load sample data in your database",
|
||||
title: "Sample Data",
|
||||
description: "Load sample data in your database",
|
||||
icon: <img src={CosmosDbBlackIcon} alt={"Azure Cosmos DB icon"} aria-hidden="true" />,
|
||||
onClick: () => setOpenSampleDataImportDialog(true),
|
||||
onClick: () => {
|
||||
setSelectedSampleDataConfiguration({
|
||||
databaseName: userContext.fabricContext?.databaseName,
|
||||
newContainerName: "SampleData",
|
||||
sampleDataFile: SampleDataFile.FABRIC_SAMPLE_DATA,
|
||||
});
|
||||
setOpenSampleDataImportDialog(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Sample Vector Data",
|
||||
description: "Load sample vector data with text-embedding-ada-002",
|
||||
icon: <img src={AzureOpenAiIcon} alt={"Azure Open AI icon"} aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
setSelectedSampleDataConfiguration({
|
||||
databaseName: userContext.fabricContext?.databaseName,
|
||||
newContainerName: "SampleVectorData",
|
||||
sampleDataFile: SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA,
|
||||
});
|
||||
setOpenSampleDataImportDialog(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "App development",
|
||||
@@ -156,17 +199,25 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
icon: <LinkMultipleRegular />,
|
||||
onClick: () => window.open("https://aka.ms/cosmosdbfabricsdk", "_blank"),
|
||||
},
|
||||
{
|
||||
title: "Sample Gallery",
|
||||
description: "Get real-world end-to-end samples",
|
||||
icon: <img src={GithubIcon} alt={"GitHub icon"} aria-hidden="true" />,
|
||||
onClick: () => window.open("https://aka.ms/CosmosFabricSamplesGallery", "_blank"),
|
||||
},
|
||||
];
|
||||
|
||||
return isFabricNativeReadOnly() ? (
|
||||
<div className={styles.buttonsContainer}>
|
||||
<FabricHomeScreenButton className={styles.single} {...buttons[2]} />
|
||||
<FabricHomeScreenButton className={styles.single} {...buttons[3]} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.buttonsContainer}>
|
||||
<FabricHomeScreenButton className={styles.one} {...buttons[0]} />
|
||||
<FabricHomeScreenButton className={styles.two} {...buttons[1]} />
|
||||
<FabricHomeScreenButton className={styles.three} {...buttons[2]} />
|
||||
<FabricHomeScreenButton className={styles.four} {...buttons[3]} />
|
||||
<FabricHomeScreenButton className={styles.five} {...buttons[4]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -179,7 +230,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
open={openSampleDataImportDialog}
|
||||
setOpen={setOpenSampleDataImportDialog}
|
||||
explorer={props.explorer}
|
||||
databaseName={userContext.fabricContext?.databaseName}
|
||||
sampleDataConfiguration={selectedSampleDataConfiguration}
|
||||
/>
|
||||
<div className={styles.title} role="heading" aria-label={title} aria-level={1}>
|
||||
{title}
|
||||
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { checkContainerExists, createContainer, importData } from "Explorer/SplashScreen/SampleUtil";
|
||||
import { checkContainerExists, createContainer, importData, SampleDataFile } from "Explorer/SplashScreen/SampleUtil";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
|
||||
const SAMPLE_DATA_CONTAINER_NAME = "SampleData";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
dialogContent: {
|
||||
alignItems: "center",
|
||||
@@ -24,6 +22,12 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
export interface SampleDataConfiguration {
|
||||
databaseName: string;
|
||||
newContainerName: string;
|
||||
sampleDataFile: SampleDataFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* This dialog:
|
||||
* - creates a container
|
||||
@@ -35,11 +39,11 @@ export const SampleDataImportDialog: React.FC<{
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
explorer: Explorer;
|
||||
databaseName: string;
|
||||
sampleDataConfiguration: SampleDataConfiguration | undefined;
|
||||
}> = (props) => {
|
||||
const [status, setStatus] = useState<"idle" | "creating" | "importing" | "completed" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const containerName = SAMPLE_DATA_CONTAINER_NAME;
|
||||
const containerName = props.sampleDataConfiguration?.newContainerName;
|
||||
const [collection, setCollection] = useState<ViewModels.Collection>(undefined);
|
||||
const styles = useStyles();
|
||||
|
||||
@@ -53,7 +57,7 @@ export const SampleDataImportDialog: React.FC<{
|
||||
|
||||
const handleStartImport = async (): Promise<void> => {
|
||||
setStatus("creating");
|
||||
const databaseName = props.databaseName;
|
||||
const databaseName = props.sampleDataConfiguration.databaseName;
|
||||
if (checkContainerExists(databaseName, containerName)) {
|
||||
const msg = `The container "${containerName}" in database "${databaseName}" already exists. Please delete it and retry.`;
|
||||
setStatus("error");
|
||||
@@ -63,7 +67,12 @@ export const SampleDataImportDialog: React.FC<{
|
||||
|
||||
let collection;
|
||||
try {
|
||||
collection = await createContainer(databaseName, containerName, props.explorer);
|
||||
collection = await createContainer(
|
||||
databaseName,
|
||||
containerName,
|
||||
props.explorer,
|
||||
props.sampleDataConfiguration.sampleDataFile,
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(`Failed to create container: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -72,7 +81,7 @@ export const SampleDataImportDialog: React.FC<{
|
||||
|
||||
try {
|
||||
setStatus("importing");
|
||||
await importData(collection);
|
||||
await importData(props.sampleDataConfiguration.sampleDataFile, collection);
|
||||
setCollection(collection);
|
||||
setStatus("completed");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { JSONObject } from "@azure/cosmos";
|
||||
import { BackendDefaults } from "Common/Constants";
|
||||
import { createCollection } from "Common/dataAccess/createCollection";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { DEFAULT_FABRIC_NATIVE_CONTAINER_THROUGHPUT, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
/**
|
||||
* Public for unit tests
|
||||
@@ -26,21 +30,81 @@ const hasContainer = (
|
||||
export const checkContainerExists = (databaseName: string, containerName: string) =>
|
||||
hasContainer(databaseName, containerName, useDatabases.getState().databases);
|
||||
|
||||
export enum SampleDataFile {
|
||||
COPILOT = "Copilot",
|
||||
FABRIC_SAMPLE_DATA = "FabricSampleData",
|
||||
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 (
|
||||
databaseName: string,
|
||||
containerName: string,
|
||||
explorer: Explorer,
|
||||
sampleDataFile: SampleDataFile,
|
||||
): Promise<ViewModels.Collection> => {
|
||||
const createRequest: DataModels.CreateCollectionParams = {
|
||||
autoPilotMaxThroughput: isFabricNative() ? DEFAULT_FABRIC_NATIVE_CONTAINER_THROUGHPUT : undefined,
|
||||
createNewDatabase: false,
|
||||
collectionId: containerName,
|
||||
databaseId: databaseName,
|
||||
databaseLevelThroughput: false,
|
||||
partitionKey: {
|
||||
paths: [`/${SAMPLE_DATA_PARTITION_KEY}`],
|
||||
paths: [`/${containerSettings[sampleDataFile].partitionKeyString}`],
|
||||
kind: "Hash",
|
||||
version: BackendDefaults.partitionKeyVersion,
|
||||
},
|
||||
vectorEmbeddingPolicy: containerSettings[sampleDataFile].vectorEmbeddingPolicy,
|
||||
indexingPolicy: containerSettings[sampleDataFile].indexingPolicy,
|
||||
};
|
||||
await createCollection(createRequest);
|
||||
await explorer.refreshAllDatabases();
|
||||
@@ -53,12 +117,39 @@ export const createContainer = async (
|
||||
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> => {
|
||||
let documents: JSONObject[] = undefined;
|
||||
switch (sampleDataFile) {
|
||||
case SampleDataFile.COPILOT:
|
||||
documents = (
|
||||
await import(/* webpackChunkName: "queryCopilotSampleData" */ "../../../sampleData/queryCopilotSampleData.json")
|
||||
).data;
|
||||
break;
|
||||
case SampleDataFile.FABRIC_SAMPLE_DATA:
|
||||
documents = (await import(/* webpackChunkName: "fabricSampleData" */ "../../../sampleData/fabricSampleData.json"))
|
||||
.default;
|
||||
break;
|
||||
case SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA:
|
||||
documents = (
|
||||
await import(
|
||||
/* webpackChunkName: "fabricSampleDataVectors" */ "../../../sampleData/fabricSampleDataVectors.json"
|
||||
)
|
||||
).default;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown sample data file: ${sampleDataFile}`);
|
||||
}
|
||||
if (!documents) {
|
||||
throw new Error(`Failed to load sample data file: ${sampleDataFile}`);
|
||||
}
|
||||
|
||||
export const importData = async (collection: ViewModels.Collection): Promise<void> => {
|
||||
// TODO: keep same chunk as ContainerSampleGenerator
|
||||
const dataFileContent = await import(
|
||||
/* webpackChunkName: "queryCopilotSampleData" */ "../../../sampleData/queryCopilotSampleData.json"
|
||||
);
|
||||
await collection.bulkInsertDocuments(dataFileContent.data);
|
||||
// Time it
|
||||
const start = performance.now();
|
||||
await collection.bulkInsertDocuments(documents);
|
||||
const end = performance.now();
|
||||
TelemetryProcessor.trace(Action.ImportSampleData, ActionModifiers.Success, {
|
||||
documentsCount: documents.length,
|
||||
durationMs: end - start,
|
||||
sampleDataFile,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import { updateDocument } from "../../Common/dataAccess/updateDocument";
|
||||
import { configContext } from "../../ConfigContext";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
|
||||
import { getAuthorizationHeader, isDataplaneRbacEnabledForProxyApi } from "../../Utils/AuthorizationUtils";
|
||||
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import Explorer from "../Explorer";
|
||||
@@ -551,6 +551,10 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
|
||||
xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token);
|
||||
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const EXIT_COMMAND_MONGO = ` printf "\\033[1;31mSession ended. Please clo
|
||||
* This command runs mongosh in no-database and quiet mode,
|
||||
* and evaluates the `disableTelemetry()` function to turn off telemetry collection.
|
||||
*/
|
||||
export const DISABLE_TELEMETRY_COMMAND = `mongosh --nodb --quiet --eval "disableTelemetry()"`;
|
||||
export const DISABLE_TELEMETRY_COMMAND = `mongosh --nodb --quiet --eval 'disableTelemetry()'`;
|
||||
|
||||
/**
|
||||
* Abstract class that defines the interface for shell-specific handlers
|
||||
@@ -97,7 +97,7 @@ export abstract class AbstractShellHandler {
|
||||
* is not already present in the environment.
|
||||
*/
|
||||
protected mongoShellSetupCommands(): string[] {
|
||||
const PACKAGE_VERSION: string = "2.5.5";
|
||||
const PACKAGE_VERSION: string = "2.5.6";
|
||||
return [
|
||||
"if ! command -v mongosh &> /dev/null; then echo '⚠️ mongosh not found. Installing...'; fi",
|
||||
`if ! command -v mongosh &> /dev/null; then curl -LO https://downloads.mongodb.com/compass/mongosh-${PACKAGE_VERSION}-linux-x64.tgz; fi`,
|
||||
|
||||
@@ -18,6 +18,12 @@ interface DatabaseAccount {
|
||||
|
||||
interface UserContextType {
|
||||
databaseAccount: DatabaseAccount;
|
||||
features: {
|
||||
enableAadDataPlane: boolean;
|
||||
};
|
||||
apiType: string;
|
||||
dataPlaneRbacEnabled: boolean;
|
||||
aadToken?: string;
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
@@ -29,6 +35,8 @@ jest.mock("../../../../UserContext", () => ({
|
||||
mongoEndpoint: "https://test-mongo.documents.azure.com:443/",
|
||||
},
|
||||
},
|
||||
features: { enableAadDataPlane: false },
|
||||
apiType: "Mongo",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -70,7 +78,7 @@ describe("MongoShellHandler", () => {
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBe(7);
|
||||
expect(commands[1]).toContain("mongosh-2.5.5-linux-x64.tgz");
|
||||
expect(commands[1]).toContain("mongosh-2.5.6-linux-x64.tgz");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,11 +96,12 @@ describe("MongoShellHandler", () => {
|
||||
kind: "test-kind",
|
||||
properties: { mongoEndpoint: "https://test-mongo.documents.azure.com:443/" },
|
||||
};
|
||||
(userContext as UserContextType).dataPlaneRbacEnabled = false;
|
||||
|
||||
const command = mongoShellHandler.getConnectionCommand();
|
||||
|
||||
expect(command).toBe(
|
||||
'mongosh --nodb --quiet --eval "disableTelemetry()" && mongosh mongodb://test-mongo.documents.azure.com:10255?appName=CosmosExplorerTerminal --username test-account --password test-key --tls --tlsAllowInvalidCertificates',
|
||||
"mongosh --nodb --quiet --eval 'disableTelemetry()'; mongosh mongodb://test-mongo.documents.azure.com:10255?appName=CosmosExplorerTerminal --username test-account --password test-key --tls --tlsAllowInvalidCertificates",
|
||||
);
|
||||
expect(CommonUtils.getHostFromUrl).toHaveBeenCalledWith("https://test-mongo.documents.azure.com:443/");
|
||||
|
||||
@@ -115,12 +124,47 @@ describe("MongoShellHandler", () => {
|
||||
};
|
||||
|
||||
const command = mongoShellHandler.getConnectionCommand();
|
||||
|
||||
expect(command).toBe("echo 'Database name not found.'");
|
||||
|
||||
// Restore original
|
||||
(userContext as UserContextType).databaseAccount = originalDatabaseAccount;
|
||||
});
|
||||
|
||||
it("should return echo if endpoint is missing", () => {
|
||||
const testKey = "test-key";
|
||||
(userContext as UserContextType).databaseAccount = {
|
||||
id: "test-id",
|
||||
name: "", // Empty name to simulate missing name
|
||||
location: "test-location",
|
||||
type: "test-type",
|
||||
kind: "test-kind",
|
||||
properties: { mongoEndpoint: "" },
|
||||
};
|
||||
const mongoShellHandler = new MongoShellHandler(testKey);
|
||||
const command = mongoShellHandler.getConnectionCommand();
|
||||
expect(command).toBe("echo 'MongoDB endpoint not found.'");
|
||||
});
|
||||
|
||||
it("should use _getAadConnectionCommand when _isEntraIdEnabled is true", () => {
|
||||
const testKey = "aad-key";
|
||||
(userContext as UserContextType).databaseAccount = {
|
||||
id: "test-id",
|
||||
name: "test-account",
|
||||
location: "test-location",
|
||||
type: "test-type",
|
||||
kind: "test-kind",
|
||||
properties: { mongoEndpoint: "https://test-mongo.documents.azure.com:443/" },
|
||||
};
|
||||
(userContext as UserContextType).dataPlaneRbacEnabled = true;
|
||||
|
||||
const mongoShellHandler = new MongoShellHandler(testKey);
|
||||
|
||||
const command = mongoShellHandler.getConnectionCommand();
|
||||
expect(command).toContain(
|
||||
"mongosh 'mongodb://test-account:aad-key@test-account.mongo.cosmos.azure.com:10255/?ssl=true&replicaSet=globaldb&authMechanism=PLAIN&retryWrites=false' --tls --tlsAllowInvalidCertificates",
|
||||
);
|
||||
expect(command.startsWith("mongosh --nodb")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTerminalSuppressedData", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { isDataplaneRbacEnabledForProxyApi } from "../../../../Utils/AuthorizationUtils";
|
||||
import { filterAndCleanTerminalOutput, getHostFromUrl, getMongoShellRemoveInfoText } from "../Utils/CommonUtils";
|
||||
import { AbstractShellHandler, DISABLE_TELEMETRY_COMMAND, EXIT_COMMAND_MONGO } from "./AbstractShellHandler";
|
||||
|
||||
@@ -6,12 +7,23 @@ export class MongoShellHandler extends AbstractShellHandler {
|
||||
private _key: string;
|
||||
private _endpoint: string | undefined;
|
||||
private _removeInfoText: string[] = getMongoShellRemoveInfoText();
|
||||
private _isEntraIdEnabled: boolean = isDataplaneRbacEnabledForProxyApi(userContext);
|
||||
constructor(private key: string) {
|
||||
super();
|
||||
this._key = key;
|
||||
this._endpoint = userContext?.databaseAccount?.properties?.mongoEndpoint;
|
||||
}
|
||||
|
||||
private _getKeyConnectionCommand(dbName: string): string {
|
||||
return `mongosh mongodb://${getHostFromUrl(this._endpoint)}:10255?appName=${
|
||||
this.APP_NAME
|
||||
} --username ${dbName} --password ${this._key} --tls --tlsAllowInvalidCertificates`;
|
||||
}
|
||||
|
||||
private _getAadConnectionCommand(dbName: string): string {
|
||||
return `mongosh 'mongodb://${dbName}:${this._key}@${dbName}.mongo.cosmos.azure.com:10255/?ssl=true&replicaSet=globaldb&authMechanism=PLAIN&retryWrites=false' --tls --tlsAllowInvalidCertificates`;
|
||||
}
|
||||
|
||||
public getShellName(): string {
|
||||
return "MongoDB";
|
||||
}
|
||||
@@ -29,19 +41,11 @@ export class MongoShellHandler extends AbstractShellHandler {
|
||||
if (!dbName) {
|
||||
return "echo 'Database name not found.'";
|
||||
}
|
||||
return (
|
||||
DISABLE_TELEMETRY_COMMAND +
|
||||
" && " +
|
||||
"mongosh mongodb://" +
|
||||
getHostFromUrl(this._endpoint) +
|
||||
":10255?appName=" +
|
||||
this.APP_NAME +
|
||||
" --username " +
|
||||
dbName +
|
||||
" --password " +
|
||||
this._key +
|
||||
" --tls --tlsAllowInvalidCertificates"
|
||||
);
|
||||
const connectionCommand = this._isEntraIdEnabled
|
||||
? this._getAadConnectionCommand(dbName)
|
||||
: this._getKeyConnectionCommand(dbName);
|
||||
const fullCommand = `${DISABLE_TELEMETRY_COMMAND}; ${connectionCommand}`;
|
||||
return fullCommand;
|
||||
}
|
||||
|
||||
public getTerminalSuppressedData(): string[] {
|
||||
|
||||
@@ -7,12 +7,24 @@ import { PostgresShellHandler } from "./PostgresShellHandler";
|
||||
import { getHandler, getKey } from "./ShellTypeFactory";
|
||||
import { VCoreMongoShellHandler } from "./VCoreMongoShellHandler";
|
||||
|
||||
interface UserContextType {
|
||||
databaseAccount: { name: string };
|
||||
subscriptionId: string;
|
||||
resourceGroup: string;
|
||||
features: { enableAadDataPlane: boolean };
|
||||
dataPlaneRbacEnabled: boolean;
|
||||
aadToken?: string;
|
||||
apiType?: string;
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../../../UserContext", () => ({
|
||||
userContext: {
|
||||
databaseAccount: { name: "testDbName" },
|
||||
subscriptionId: "testSubId",
|
||||
resourceGroup: "testResourceGroup",
|
||||
features: { enableAadDataPlane: false },
|
||||
dataPlaneRbacEnabled: false,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -109,5 +121,33 @@ describe("ShellTypeHandlerFactory", () => {
|
||||
expect(key).toBe(mockKey);
|
||||
expect(listKeys).toHaveBeenCalledWith("testSubId", "testResourceGroup", "testDbName");
|
||||
});
|
||||
|
||||
it("should return MongoShellHandler with primaryMasterKey for TerminalKind.Mongo when RBAC is disabled", async () => {
|
||||
(listKeys as jest.Mock).mockResolvedValue({ primaryMasterKey: "primaryKey123" });
|
||||
(userContext as UserContextType).features.enableAadDataPlane = false;
|
||||
(userContext as UserContextType).dataPlaneRbacEnabled = false;
|
||||
const handler = await getHandler(TerminalKind.Mongo);
|
||||
expect(handler).toBeInstanceOf(MongoShellHandler);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
expect(handler.key).toBe("primaryKey123");
|
||||
});
|
||||
|
||||
it("should return MongoShellHandler with aadToken for TerminalKind.Mongo when RBAC is enabled", async () => {
|
||||
(userContext as UserContextType).aadToken = "aadToken123";
|
||||
(userContext as UserContextType).features.enableAadDataPlane = true;
|
||||
(userContext as UserContextType).dataPlaneRbacEnabled = true;
|
||||
(userContext as UserContextType).apiType = "Mongo";
|
||||
const handler = await getHandler(TerminalKind.Mongo);
|
||||
expect(handler).toBeInstanceOf(MongoShellHandler);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
expect(handler.key).toBe("aadToken123");
|
||||
});
|
||||
it("should throw error for unsupported shell type", async () => {
|
||||
await expect(getHandler("UnknownShell" as unknown as TerminalKind)).rejects.toThrow(
|
||||
"Unsupported shell type: UnknownShell",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TerminalKind } from "../../../../Contracts/ViewModels";
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { listKeys } from "../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { isDataplaneRbacEnabledForProxyApi } from "../../../../Utils/AuthorizationUtils";
|
||||
import { AbstractShellHandler } from "./AbstractShellHandler";
|
||||
import { CassandraShellHandler } from "./CassandraShellHandler";
|
||||
import { MongoShellHandler } from "./MongoShellHandler";
|
||||
@@ -30,6 +31,9 @@ export async function getKey(): Promise<string> {
|
||||
if (!dbName) {
|
||||
return "";
|
||||
}
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
return userContext.aadToken || "";
|
||||
}
|
||||
|
||||
const keys = await listKeys(userContext.subscriptionId, userContext.resourceGroup, dbName);
|
||||
return keys?.primaryMasterKey || "";
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("VCoreMongoShellHandler", () => {
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBe(7);
|
||||
expect(commands[1]).toContain("mongosh-2.5.5-linux-x64.tgz");
|
||||
expect(commands[1]).toContain("mongosh-2.5.6-linux-x64.tgz");
|
||||
expect(commands[0]).toContain("mongosh not found");
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,18 @@ export class AttachAddon implements ITerminalAddon {
|
||||
* @param {Terminal} terminal - The XTerm terminal instance
|
||||
*/
|
||||
public addMessageListener(terminal: Terminal): void {
|
||||
let messageBuffer = "";
|
||||
let bufferTimeout: NodeJS.Timeout | null = null;
|
||||
const BUFFER_TIMEOUT = 50; // ms - short timeout for prompt detection
|
||||
|
||||
const processBuffer = () => {
|
||||
if (messageBuffer.length > 0) {
|
||||
this.handleCompleteTerminalData(terminal, messageBuffer);
|
||||
messageBuffer = "";
|
||||
}
|
||||
bufferTimeout = null;
|
||||
};
|
||||
|
||||
this._disposables.push(
|
||||
addSocketListener(this._socket, "message", (ev) => {
|
||||
let data: ArrayBuffer | string = ev.data;
|
||||
@@ -103,57 +115,136 @@ export class AttachAddon implements ITerminalAddon {
|
||||
data = enc.decode(ev.data as ArrayBuffer);
|
||||
}
|
||||
|
||||
// for example of json object look in TerminalHelper in the socket.onMessage
|
||||
if (data.includes(startStatusJson) && data.includes(endStatusJson)) {
|
||||
// process as one line
|
||||
const statusData = data.split(startStatusJson)[1].split(endStatusJson)[0];
|
||||
data = data.replace(statusData, "");
|
||||
data = data.replace(startStatusJson, "");
|
||||
data = data.replace(endStatusJson, "");
|
||||
} else if (data.includes(startStatusJson)) {
|
||||
// check for start
|
||||
const partialStatusData = data.split(startStatusJson)[1];
|
||||
this._socketData += partialStatusData;
|
||||
data = data.replace(partialStatusData, "");
|
||||
data = data.replace(startStatusJson, "");
|
||||
} else if (data.includes(endStatusJson)) {
|
||||
// check for end and process the command
|
||||
const partialStatusData = data.split(endStatusJson)[0];
|
||||
this._socketData += partialStatusData;
|
||||
data = data.replace(partialStatusData, "");
|
||||
data = data.replace(endStatusJson, "");
|
||||
this._socketData = "";
|
||||
} else if (this._socketData.length > 0) {
|
||||
// check if the line is all data then just concatenate
|
||||
this._socketData += data;
|
||||
data = "";
|
||||
}
|
||||
// Handle status messages
|
||||
let processedStatusData = data;
|
||||
|
||||
if (this._allowTerminalWrite && data.includes(this._startMarker)) {
|
||||
this._allowTerminalWrite = false;
|
||||
terminal.write(`Preparing ${this._shellHandler.getShellName()} environment...\r\n`);
|
||||
}
|
||||
|
||||
if (this._allowTerminalWrite) {
|
||||
const updatedData =
|
||||
typeof this._shellHandler?.updateTerminalData === "function"
|
||||
? this._shellHandler.updateTerminalData(data)
|
||||
: data;
|
||||
|
||||
const suppressedData = this._shellHandler?.getTerminalSuppressedData();
|
||||
|
||||
const shouldNotWrite = suppressedData.filter(Boolean).some((item) => updatedData.includes(item));
|
||||
|
||||
if (!shouldNotWrite) {
|
||||
terminal.write(updatedData);
|
||||
// Process status messages with delimiters
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const startIndex = processedStatusData.indexOf(startStatusJson);
|
||||
if (startIndex === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const afterStart = processedStatusData.substring(startIndex + startStatusJson.length);
|
||||
const endIndex = afterStart.indexOf(endStatusJson);
|
||||
|
||||
if (endIndex === -1) {
|
||||
// Incomplete status message
|
||||
this._socketData += processedStatusData.substring(startIndex);
|
||||
processedStatusData = processedStatusData.substring(0, startIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove processed status message
|
||||
processedStatusData =
|
||||
processedStatusData.substring(0, startIndex) + afterStart.substring(endIndex + endStatusJson.length);
|
||||
}
|
||||
|
||||
if (data.includes(this._shellHandler.getConnectionCommand())) {
|
||||
this._allowTerminalWrite = true;
|
||||
// Add to message buffer
|
||||
messageBuffer += processedStatusData;
|
||||
|
||||
// Clear existing timeout
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
bufferTimeout = null;
|
||||
}
|
||||
|
||||
// Check if this looks like a complete message/command
|
||||
const isComplete = this.isMessageComplete(messageBuffer, processedStatusData);
|
||||
|
||||
if (isComplete) {
|
||||
// Message marked as complete, processing immediately
|
||||
processBuffer();
|
||||
} else {
|
||||
// Set timeout to process buffer after delay
|
||||
bufferTimeout = setTimeout(processBuffer, BUFFER_TIMEOUT);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Clean up timeout on dispose
|
||||
this._disposables.push({
|
||||
dispose: () => {
|
||||
if (bufferTimeout) {
|
||||
clearTimeout(bufferTimeout);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private isMessageComplete(fullBuffer: string, currentChunk: string): boolean {
|
||||
// Immediate completion indicators
|
||||
const immediateCompletionPatterns = [
|
||||
/\n$/, // Ends with newline
|
||||
/\r$/, // Ends with carriage return
|
||||
/\r\n$/, // Ends with CRLF
|
||||
/; \} \|\| true;$/, // Your command pattern
|
||||
/disown -a && exit$/, // Exit commands
|
||||
/printf.*?\\033\[0m\\n"$/, // Your printf pattern
|
||||
];
|
||||
|
||||
// Check current chunk for immediate completion
|
||||
for (const pattern of immediateCompletionPatterns) {
|
||||
if (pattern.test(currentChunk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ANSI sequence detection - these might be complete prompts
|
||||
const ansiPromptPatterns = [
|
||||
/\[\d+G\[0J.*>\s*\[\d+G$/, // Your specific pattern: [1G[0J...> [26G
|
||||
/\[\d+;\d+H/, // Cursor position sequences
|
||||
/\]\s*\[\d+G$/, // Ends with cursor positioning
|
||||
/>\s*\[\d+G$/, // Prompt followed by cursor position
|
||||
];
|
||||
|
||||
// Check if buffer ends with what looks like a complete prompt
|
||||
for (const pattern of ansiPromptPatterns) {
|
||||
if (pattern.test(fullBuffer)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for MongoDB shell prompts specifically
|
||||
const mongoPromptPatterns = [
|
||||
/globaldb \[primary\] \w+>\s*\[\d+G$/, // MongoDB replica set prompt
|
||||
/>\s*\[\d+G$/, // General prompt with cursor positioning
|
||||
/\w+>\s*$/, // Simple shell prompt
|
||||
];
|
||||
|
||||
for (const pattern of mongoPromptPatterns) {
|
||||
if (pattern.test(fullBuffer)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private handleCompleteTerminalData(terminal: Terminal, data: string): void {
|
||||
if (this._allowTerminalWrite && data.includes(this._startMarker)) {
|
||||
this._allowTerminalWrite = false;
|
||||
terminal.write(`Preparing ${this._shellHandler.getShellName()} environment...\r\n`);
|
||||
}
|
||||
|
||||
if (this._allowTerminalWrite) {
|
||||
const updatedData =
|
||||
typeof this._shellHandler?.updateTerminalData === "function"
|
||||
? this._shellHandler.updateTerminalData(data)
|
||||
: data;
|
||||
|
||||
const suppressedData = this._shellHandler?.getTerminalSuppressedData();
|
||||
const shouldNotWrite = suppressedData.filter(Boolean).some((item) => updatedData.includes(item));
|
||||
|
||||
if (!shouldNotWrite) {
|
||||
terminal.write(updatedData);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.includes(this._shellHandler.getConnectionCommand())) {
|
||||
this._allowTerminalWrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
|
||||
@@ -146,10 +146,16 @@ describe("Documents tab (Mongo API)", () => {
|
||||
updateConfigContext({ platform: Platform.Hosted });
|
||||
|
||||
const props: IDocumentsTabComponentProps = createMockProps();
|
||||
|
||||
wrapper = mount(<DocumentsTabComponent {...props} />);
|
||||
wrapper = await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
// Wait for all pending promises
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
// Wait for any async operations to complete
|
||||
wrapper = await waitForComponentToPaint(wrapper, 100);
|
||||
}, 10000);
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
|
||||
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { CircleFilled } from "@fluentui/react-icons";
|
||||
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
|
||||
import * as React from "react";
|
||||
|
||||
// SDK response format
|
||||
export interface IndexMetricsResponse {
|
||||
UtilizedIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
PotentialIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
|
||||
included: IIndexMetric[];
|
||||
notIncluded: IIndexMetric[];
|
||||
} {
|
||||
const included: IIndexMetric[] = [];
|
||||
const notIncluded: IIndexMetric[] = [];
|
||||
|
||||
// Process UtilizedIndexes (Included in Current Policy)
|
||||
if (indexMetrics.UtilizedIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
|
||||
included.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
included.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process PotentialIndexes (Not Included in Current Policy)
|
||||
if (indexMetrics.PotentialIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
|
||||
notIncluded.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
notIncluded.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { included, notIncluded };
|
||||
}
|
||||
|
||||
export const renderImpactDots = (impact: string): JSX.Element => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
let count = 0;
|
||||
|
||||
if (impact === "High") {
|
||||
count = 3;
|
||||
} else if (impact === "Medium") {
|
||||
count = 2;
|
||||
} else if (impact === "Low") {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.indexAdvisorImpactDots}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,21 @@ import QueryError from "Common/QueryError";
|
||||
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import React from "react";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
import RunQuery from "../../../../images/RunQuery.png";
|
||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||
import { ErrorList } from "./ErrorList";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
|
||||
export interface ResultsViewProps {
|
||||
isMongoDB: boolean;
|
||||
queryResults: QueryResults;
|
||||
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
interface QueryResultProps extends ResultsViewProps {
|
||||
@@ -49,6 +52,8 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
isExecuting,
|
||||
databaseId,
|
||||
containerId,
|
||||
}: QueryResultProps): JSX.Element => {
|
||||
const styles = useQueryTabStyles();
|
||||
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
||||
@@ -91,6 +96,9 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults={queryResults}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
isMongoDB={isMongoDB}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
) : (
|
||||
<ExecuteQueryCallToAction />
|
||||
|
||||
@@ -52,8 +52,9 @@ describe("QueryTabComponent", () => {
|
||||
copilotVersion: "v3.0",
|
||||
},
|
||||
});
|
||||
|
||||
const propsMock: Readonly<IQueryTabComponentProps> = {
|
||||
collection: { databaseId: "CopilotSampleDB" },
|
||||
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
|
||||
onTabAccessor: () => jest.fn(),
|
||||
isExecutionError: false,
|
||||
tabId: "mockTabId",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { TabsState, useTabs } from "hooks/useTabs";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
import "react-splitter-layout/lib/index.css";
|
||||
import { format } from "react-string-format";
|
||||
import create from "zustand";
|
||||
//TODO: Uncomment next two lines when query copilot is reinstated in DE
|
||||
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
|
||||
@@ -56,6 +57,20 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
|
||||
import TabsBase from "../TabsBase";
|
||||
import "./QueryTabComponent.less";
|
||||
|
||||
export interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
|
||||
enum ToggleState {
|
||||
Result,
|
||||
QueryMetrics,
|
||||
@@ -260,6 +275,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
const query1 = this.state.sqlQueryEditorContent;
|
||||
const db = this.props.collection.databaseId;
|
||||
const container = this.props.collection.id();
|
||||
useQueryMetadataStore.getState().setMetadata(query1, db, container);
|
||||
this._iterator = undefined;
|
||||
|
||||
setTimeout(async () => {
|
||||
@@ -775,6 +794,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.props.copilotStore?.errors}
|
||||
isExecuting={this.props.copilotStore?.isExecuting}
|
||||
queryResults={this.props.copilotStore?.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
QueryDocumentsPerPage(
|
||||
firstItemIndex,
|
||||
@@ -790,6 +811,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.state.errors}
|
||||
isExecuting={this.state.isExecuting}
|
||||
queryResults={this.state.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
this._executeQueryDocumentsPage(firstItemIndex)
|
||||
}
|
||||
|
||||
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import React from "react";
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
const mockFetchAll = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockLogConsoleProgress = jest.fn();
|
||||
const mockHandleError = jest.fn();
|
||||
|
||||
const indexMetricsResponse = {
|
||||
UtilizedIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
|
||||
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
|
||||
},
|
||||
PotentialIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
|
||||
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
|
||||
},
|
||||
};
|
||||
|
||||
const mockQueryResults = {
|
||||
documents: [] as unknown[],
|
||||
hasMoreResults: false,
|
||||
itemCount: 0,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
requestCharge: 0,
|
||||
activityId: "test-activity-id",
|
||||
};
|
||||
|
||||
mockRead.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
partitionKey: "pk",
|
||||
},
|
||||
});
|
||||
|
||||
mockReplace.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock("Common/CosmosClient", () => ({
|
||||
client: () => ({
|
||||
database: () => ({
|
||||
container: () => ({
|
||||
items: {
|
||||
query: () => ({
|
||||
fetchAll: mockFetchAll,
|
||||
}),
|
||||
},
|
||||
read: mockRead,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./StylesAdvisor", () => ({
|
||||
useIndexAdvisorStyles: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
|
||||
logConsoleProgress: (...args: unknown[]) => {
|
||||
mockLogConsoleProgress(...args);
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
|
||||
handleError: (...args: unknown[]) => mockHandleError(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
|
||||
});
|
||||
|
||||
describe("IndexAdvisorTab Basic Tests", () => {
|
||||
test("component renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders component and handles missing parameters", () => {
|
||||
const { container } = render(<IndexAdvisorTab />);
|
||||
expect(container).toBeTruthy();
|
||||
// Should not crash when parameters are missing
|
||||
});
|
||||
|
||||
test("fetches index metrics with query results", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("displays content after loading", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
// Wait for the component to finish loading
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
// Component should have rendered some content
|
||||
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls log console progress when fetching metrics", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("handles error when fetch fails", async () => {
|
||||
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
|
||||
});
|
||||
|
||||
test("renders with all required props", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="testDb"
|
||||
containerId="testContainer"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
|
||||
import { FontIcon } from "@fluentui/react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
@@ -8,28 +11,45 @@ import {
|
||||
DataGridRow,
|
||||
SelectTabData,
|
||||
SelectTabEvent,
|
||||
Spinner,
|
||||
Tab,
|
||||
TabList,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumnDefinition,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
|
||||
import copy from "clipboard-copy";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { QueryMetrics } from "Contracts/DataModels";
|
||||
import { QueryResults } from "Contracts/ViewModels";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import {
|
||||
parseIndexMetrics,
|
||||
renderImpactDots,
|
||||
type IndexMetricsResponse,
|
||||
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
|
||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import copy from "clipboard-copy";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
import create from "zustand";
|
||||
import { client } from "../../../Common/CosmosClient";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { sampleDataClient } from "../../../Common/SampleDataClient";
|
||||
import { ResultsViewProps } from "./QueryResultSection";
|
||||
|
||||
import { useIndexAdvisorStyles } from "./StylesAdvisor";
|
||||
enum ResultsTabs {
|
||||
Results = "results",
|
||||
QueryStats = "queryStats",
|
||||
IndexAdvisor = "indexadv",
|
||||
}
|
||||
|
||||
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
/* eslint-disable react/prop-types */
|
||||
@@ -523,14 +543,331 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
|
||||
);
|
||||
};
|
||||
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||
export interface IIndexMetric {
|
||||
index: string;
|
||||
impact: string;
|
||||
section: "Included" | "Not Included" | "Header";
|
||||
path?: string;
|
||||
composite?: { path: string; order: string }[];
|
||||
}
|
||||
export const IndexAdvisorTab: React.FC<{
|
||||
queryResults?: QueryResults;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
|
||||
const [showIncluded, setShowIncluded] = useState(true);
|
||||
const [showNotIncluded, setShowNotIncluded] = useState(true);
|
||||
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [updateMessageShown, setUpdateMessageShown] = useState(false);
|
||||
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
|
||||
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
|
||||
|
||||
const fetchIndexMetrics = async () => {
|
||||
if (!queryEditorContent || !databaseId || !containerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
|
||||
try {
|
||||
const querySpec = {
|
||||
query: queryEditorContent,
|
||||
};
|
||||
|
||||
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
|
||||
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
|
||||
|
||||
const sdkResponse = await cosmosClient
|
||||
.database(databaseId)
|
||||
.container(containerId)
|
||||
.items.query(querySpec, {
|
||||
populateIndexMetrics: true,
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
const parsedMetrics =
|
||||
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
|
||||
|
||||
setIndexMetrics(parsedMetrics);
|
||||
} catch (error) {
|
||||
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
|
||||
} finally {
|
||||
clearMessage();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
|
||||
useEffect(() => {
|
||||
if (queryEditorContent && databaseId && containerId && queryResults) {
|
||||
fetchIndexMetrics();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
|
||||
setIncludedIndexes(included);
|
||||
setNotIncludedIndexes(notIncluded);
|
||||
if (justUpdatedPolicy) {
|
||||
setJustUpdatedPolicy(false);
|
||||
} else {
|
||||
setUpdateMessageShown(false);
|
||||
}
|
||||
}, [indexMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const allSelected =
|
||||
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
|
||||
setSelectAll(allSelected);
|
||||
}, [selectedIndexes, notIncluded]);
|
||||
|
||||
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIndexes((prev) => [...prev, indexObj]);
|
||||
} else {
|
||||
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked);
|
||||
setSelectedIndexes(checked ? notIncluded : []);
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const containerRef = client().database(databaseId).container(containerId);
|
||||
const { resource: containerDef } = await containerRef.read();
|
||||
|
||||
const newIncludedPaths = selectedIndexes
|
||||
.filter((index) => !index.composite)
|
||||
.map((index) => {
|
||||
return {
|
||||
path: index.path,
|
||||
};
|
||||
});
|
||||
|
||||
const newCompositeIndexes: CompositePath[][] = selectedIndexes
|
||||
.filter((index) => Array.isArray(index.composite))
|
||||
.map(
|
||||
(index) =>
|
||||
(index.composite as { path: string; order: string }[]).map((comp) => ({
|
||||
path: comp.path,
|
||||
order: comp.order === "descending" ? "descending" : "ascending",
|
||||
})) as CompositePath[],
|
||||
);
|
||||
|
||||
const updatedPolicy: IndexingPolicy = {
|
||||
...containerDef.indexingPolicy,
|
||||
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
|
||||
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
|
||||
automatic: containerDef.indexingPolicy?.automatic ?? true,
|
||||
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
|
||||
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
|
||||
};
|
||||
await containerRef.replace({
|
||||
id: containerId,
|
||||
partitionKey: containerDef.partitionKey,
|
||||
indexingPolicy: updatedPolicy,
|
||||
});
|
||||
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
|
||||
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
|
||||
const updatedNotIncluded: typeof notIncluded = [];
|
||||
const newlyIncluded: typeof included = [];
|
||||
for (const item of notIncluded) {
|
||||
if (selectedIndexSet.has(item.index)) {
|
||||
newlyIncluded.push(item);
|
||||
} else {
|
||||
updatedNotIncluded.push(item);
|
||||
}
|
||||
}
|
||||
const newIncluded = [...included, ...newlyIncluded];
|
||||
const newNotIncluded = updatedNotIncluded;
|
||||
setIncludedIndexes(newIncluded);
|
||||
setNotIncludedIndexes(newNotIncluded);
|
||||
setSelectedIndexes([]);
|
||||
setSelectAll(false);
|
||||
setUpdateMessageShown(true);
|
||||
setJustUpdatedPolicy(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to update indexing policy:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (item: IIndexMetric, index: number) => {
|
||||
const isHeader = item.section === "Header";
|
||||
const isNotIncluded = item.section === "Not Included";
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
{isNotIncluded ? (
|
||||
<Checkbox
|
||||
checked={selectedIndexes.some((selected) => selected.index === item.index)}
|
||||
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
|
||||
/>
|
||||
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
|
||||
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
|
||||
) : (
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
)}
|
||||
{isHeader ? (
|
||||
<span
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (item.index === "Included in Current Policy") {
|
||||
setShowIncluded(!showIncluded);
|
||||
} else if (item.index === "Not Included in Current Policy") {
|
||||
setShowNotIncluded(!showNotIncluded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index === "Included in Current Policy" ? (
|
||||
showIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)
|
||||
) : showNotIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
)}
|
||||
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
|
||||
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
|
||||
{!isHeader && item.impact}
|
||||
</div>
|
||||
<div>{!isHeader && renderImpactDots(item.impact)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
const indexMetricItems = React.useMemo(() => {
|
||||
const items: IIndexMetric[] = [];
|
||||
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showNotIncluded) {
|
||||
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
|
||||
}
|
||||
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showIncluded) {
|
||||
included.forEach((item) => items.push({ ...item, section: "Included" }));
|
||||
}
|
||||
return items;
|
||||
}, [included, notIncluded, showIncluded, showNotIncluded]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner
|
||||
size="small"
|
||||
style={
|
||||
{
|
||||
"--spinner-size": "16px",
|
||||
"--spinner-thickness": "2px",
|
||||
"--spinner-color": "#0078D4",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={style.indexAdvisorMessage}>
|
||||
{updateMessageShown ? (
|
||||
<>
|
||||
<span className={style.indexAdvisorSuccessIcon}>
|
||||
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
|
||||
</span>
|
||||
<span>
|
||||
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
|
||||
Settings.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
|
||||
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
|
||||
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
|
||||
Learn more about Indexing Metrics
|
||||
</a>
|
||||
.{" "}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
|
||||
<Table className={style.indexAdvisorTable}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
<div>Index</div>
|
||||
<div>
|
||||
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
|
||||
</Table>
|
||||
{selectedIndexes.length > 0 && (
|
||||
<div className={style.indexAdvisorButtonBar}>
|
||||
{isUpdating ? (
|
||||
<div className={style.indexAdvisorButtonSpinner}>
|
||||
<Spinner size="tiny" />{" "}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
|
||||
Update Indexing Policy with selected index(es)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({
|
||||
isMongoDB,
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
queryEditorContent,
|
||||
databaseId,
|
||||
containerId,
|
||||
}) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||
|
||||
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||
setActiveTab(data.value as ResultsTabs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||
@@ -548,6 +885,13 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
>
|
||||
Query Stats
|
||||
</Tab>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
|
||||
id={ResultsTabs.IndexAdvisor}
|
||||
value={ResultsTabs.IndexAdvisor}
|
||||
>
|
||||
Index Advisor
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={styles.queryResultsTabContentContainer}>
|
||||
{activeTab === ResultsTabs.Results && (
|
||||
@@ -558,7 +902,30 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
/>
|
||||
)}
|
||||
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||
{activeTab === ResultsTabs.IndexAdvisor && (
|
||||
<IndexAdvisorTab
|
||||
queryResults={queryResults}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export interface IndexingPolicyStore {
|
||||
indexingPolicies: { [containerId: string]: IndexingPolicy };
|
||||
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
|
||||
}
|
||||
|
||||
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
|
||||
indexingPolicies: {},
|
||||
setIndexingPolicyFor: (containerId, indexingPolicy) =>
|
||||
set((state) => ({
|
||||
indexingPolicies: {
|
||||
...state.indexingPolicies,
|
||||
[containerId]: { ...indexingPolicy },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
|
||||
export const useIndexAdvisorStyles = makeStyles({
|
||||
indexAdvisorMessage: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.2rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
indexAdvisorSuccessIcon: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#107C10",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
indexAdvisorTitle: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.3rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorTable: {
|
||||
display: "block",
|
||||
alignItems: "center",
|
||||
marginBottom: "7rem",
|
||||
},
|
||||
indexAdvisorGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "30px 30px 1fr 50px 120px",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorCheckboxSpacer: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
},
|
||||
indexAdvisorChevronSpacer: {
|
||||
width: "24px",
|
||||
},
|
||||
indexAdvisorRowBold: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorRowNormal: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorRowImpactHeader: {
|
||||
fontSize: 0,
|
||||
},
|
||||
indexAdvisorRowImpact: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorImpactDot: {
|
||||
color: "#0078D4",
|
||||
fontSize: "12px",
|
||||
display: "inline-flex",
|
||||
},
|
||||
indexAdvisorImpactDots: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
},
|
||||
indexAdvisorButtonBar: {
|
||||
padding: "1rem",
|
||||
marginTop: "-7rem",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
indexAdvisorButtonSpinner: {
|
||||
marginTop: "1rem",
|
||||
minWidth: "320px",
|
||||
minHeight: "40px",
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
justifyContent: "left",
|
||||
marginLeft: "10rem",
|
||||
},
|
||||
indexAdvisorButton: {
|
||||
backgroundColor: "#0078D4",
|
||||
color: "white",
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
marginTop: "1rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s",
|
||||
":hover": {
|
||||
backgroundColor: "#005a9e",
|
||||
},
|
||||
},
|
||||
});
|
||||
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
@@ -4,6 +4,10 @@ import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import { CosmosDbArtifactType, ResourceTokenInfo } from "Contracts/FabricMessagesContract";
|
||||
import { FabricArtifactInfo, updateUserContext, userContext } from "UserContext";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import * as AutoPilotUtils from "../../Utils/AutoPilotUtils";
|
||||
|
||||
// Fabric Native accounts are always autoscale and have a fixed throughput of 5K
|
||||
export const DEFAULT_FABRIC_NATIVE_CONTAINER_THROUGHPUT = AutoPilotUtils.autoPilotThroughput5K;
|
||||
|
||||
const TOKEN_VALIDITY_MS = (3600 - 600) * 1000; // 1 hour minus 10 minutes to be safe
|
||||
const DEBOUNCE_DELAY_MS = 1000 * 20; // 20 second
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum StorageKey {
|
||||
DefaultQueryResultsView,
|
||||
AppState,
|
||||
MongoGuidRepresentation,
|
||||
IgnorePartitionKeyOnDocumentUpdate,
|
||||
}
|
||||
|
||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||
|
||||
@@ -150,6 +150,7 @@ export enum Action {
|
||||
CloudShellUserConsent,
|
||||
CloudShellTerminalSession,
|
||||
OpenVSCode,
|
||||
ImportSampleData,
|
||||
}
|
||||
|
||||
export const ActionModifiers = {
|
||||
|
||||
@@ -91,5 +91,11 @@ export const getItemName = (): string => {
|
||||
};
|
||||
|
||||
export const isDataplaneRbacSupported = (apiType: string): boolean => {
|
||||
return apiType === "SQL" || apiType === "Tables" || apiType === "Gremlin";
|
||||
return (
|
||||
apiType === "SQL" || apiType === "Tables" || apiType === "Gremlin" || apiType === "Mongo" || apiType === "Cassandra"
|
||||
);
|
||||
};
|
||||
|
||||
export const hasProxyServer = (apiType: string): boolean => {
|
||||
return apiType === "Mongo" || apiType === "Cassandra";
|
||||
};
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("AuthorizationUtils", () => {
|
||||
|
||||
it("should return true if dataPlaneRbacEnabled is set to true and API supports RBAC", () => {
|
||||
setAadDataPlane(false);
|
||||
["SQL", "Tables", "Gremlin"].forEach((type) => {
|
||||
["SQL", "Tables", "Gremlin", "Mongo", "Cassandra"].forEach((type) => {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: true,
|
||||
apiType: type as ApiType,
|
||||
@@ -115,7 +115,7 @@ describe("AuthorizationUtils", () => {
|
||||
|
||||
it("should return false if dataPlaneRbacEnabled is set to true and API does not support RBAC", () => {
|
||||
setAadDataPlane(false);
|
||||
["Mongo", "Cassandra", "Postgres", "VCoreMongo"].forEach((type) => {
|
||||
["Postgres", "VCoreMongo"].forEach((type) => {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: true,
|
||||
apiType: type as ApiType,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as msal from "@azure/msal-browser";
|
||||
import { getEnvironmentScopeEndpoint } from "Common/EnvironmentUtility";
|
||||
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { isDataplaneRbacSupported } from "Utils/APITypeUtils";
|
||||
import { hasProxyServer, isDataplaneRbacSupported } from "Utils/APITypeUtils";
|
||||
import { AuthType } from "../AuthType";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import * as Logger from "../Common/Logger";
|
||||
@@ -74,10 +75,12 @@ export async function acquireMsalTokenForAccount(
|
||||
if (userContext.databaseAccount.properties?.documentEndpoint === undefined) {
|
||||
throw new Error("Database account has no document endpoint defined");
|
||||
}
|
||||
const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace(
|
||||
/\/+$/,
|
||||
"/.default",
|
||||
);
|
||||
let hrefEndpoint = "";
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
hrefEndpoint = getEnvironmentScopeEndpoint();
|
||||
} else {
|
||||
hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace(/\/+$/, "/.default");
|
||||
}
|
||||
const msalInstance = await getMsalInstance();
|
||||
const knownAccounts = msalInstance.getAllAccounts();
|
||||
// If user_hint is provided, we will try to use it to find the account.
|
||||
@@ -183,7 +186,11 @@ export async function acquireTokenWithMsal(
|
||||
|
||||
export function useDataplaneRbacAuthorization(userContext: UserContext): boolean {
|
||||
return (
|
||||
userContext.features.enableAadDataPlane ||
|
||||
userContext.features?.enableAadDataPlane ||
|
||||
(userContext.dataPlaneRbacEnabled && isDataplaneRbacSupported(userContext.apiType))
|
||||
);
|
||||
}
|
||||
|
||||
export function isDataplaneRbacEnabledForProxyApi(userContext: UserContext): boolean {
|
||||
return useDataplaneRbacAuthorization(userContext) && hasProxyServer(userContext.apiType);
|
||||
}
|
||||
|
||||
@@ -24,3 +24,10 @@ export const isVectorSearchEnabled = (): boolean => {
|
||||
(isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLVectorSearch) || isFabricNative())
|
||||
);
|
||||
};
|
||||
|
||||
export const isFullTextSearchPreviewFeaturesEnabled = (): boolean => {
|
||||
return (
|
||||
userContext.apiType === "SQL" &&
|
||||
isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLFullTextSearchPreviewFeatures)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Constants from "Common/Constants";
|
||||
import { getEnvironmentScopeEndpoint } from "Common/EnvironmentUtility";
|
||||
import { createUri } from "Common/UrlUtility";
|
||||
import { DATA_EXPLORER_RPC_VERSION } from "Contracts/DataExplorerMessagesContract";
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
acquireTokenWithMsal,
|
||||
getAuthorizationHeader,
|
||||
getMsalInstance,
|
||||
isDataplaneRbacEnabledForProxyApi,
|
||||
} from "../Utils/AuthorizationUtils";
|
||||
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
|
||||
import { get, getReadOnlyKeys, listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
@@ -331,7 +333,12 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
|
||||
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
|
||||
let aadToken;
|
||||
if (account.properties?.documentEndpoint) {
|
||||
const hrefEndpoint = new URL(account.properties.documentEndpoint).href.replace(/\/$/, "/.default");
|
||||
let hrefEndpoint = "";
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
hrefEndpoint = getEnvironmentScopeEndpoint();
|
||||
} else {
|
||||
hrefEndpoint = new URL(account.properties.documentEndpoint).href.replace(/\/$/, "/.default");
|
||||
}
|
||||
const msalInstance = await getMsalInstance();
|
||||
const cachedAccount = msalInstance.getAllAccounts()?.[0];
|
||||
msalInstance.setActiveAccount(cachedAccount);
|
||||
|
||||
79
test/fx.ts
79
test/fx.ts
@@ -87,27 +87,70 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s
|
||||
params.set("feature.enableCopilot", "false");
|
||||
|
||||
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
|
||||
if (nosqlRbacToken) {
|
||||
params.set("nosqlRbacToken", nosqlRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
|
||||
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
|
||||
if (nosqlReadOnlyRbacToken) {
|
||||
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
|
||||
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
|
||||
if (tableRbacToken) {
|
||||
params.set("tableRbacToken", tableRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
|
||||
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
|
||||
if (gremlinRbacToken) {
|
||||
params.set("gremlinRbacToken", gremlinRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
|
||||
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
|
||||
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
|
||||
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
|
||||
|
||||
switch (accountType) {
|
||||
case TestAccount.SQL:
|
||||
if (nosqlRbacToken) {
|
||||
params.set("nosqlRbacToken", nosqlRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.SQLReadOnly:
|
||||
if (nosqlReadOnlyRbacToken) {
|
||||
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.Tables:
|
||||
if (tableRbacToken) {
|
||||
params.set("tableRbacToken", tableRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.Gremlin:
|
||||
if (gremlinRbacToken) {
|
||||
params.set("gremlinRbacToken", gremlinRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.Cassandra:
|
||||
if (cassandraRbacToken) {
|
||||
params.set("cassandraRbacToken", cassandraRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.Mongo:
|
||||
if (mongoRbacToken) {
|
||||
params.set("mongoRbacToken", mongoRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.Mongo32:
|
||||
if (mongo32RbacToken) {
|
||||
params.set("mongo32RbacToken", mongo32RbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
|
||||
case TestAccount.MongoReadonly:
|
||||
if (mongoReadOnlyRbacToken) {
|
||||
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
|
||||
params.set("enableaaddataplane", "true");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (iframeSrc) {
|
||||
|
||||
@@ -18,6 +18,13 @@ const nosqlReadOnlyRbacToken =
|
||||
const tableRbacToken = urlSearchParams.get("tableRbacToken") || process.env.TABLE_TESTACCOUNT_TOKEN || "";
|
||||
const gremlinRbacToken = urlSearchParams.get("gremlinRbacToken") || process.env.GREMLIN_TESTACCOUNT_TOKEN || "";
|
||||
|
||||
const cassandraRbacToken = urlSearchParams.get("cassandraRbacToken") || process.env.CASSANDRA_TESTACCOUNT_TOKEN || "";
|
||||
|
||||
const mongoRbacToken = urlSearchParams.get("mongoRbacToken") || process.env.MONGO_TESTACCOUNT_TOKEN || "";
|
||||
const mongo32RbacToken = urlSearchParams.get("mongo32RbacToken") || process.env.MONGO32_TESTACCOUNT_TOKEN || "";
|
||||
const mongoReadOnlyRbacToken =
|
||||
urlSearchParams.get("mongoReadOnlyRbacToken") || process.env.MONGO_READONLY_TESTACCOUNT_TOKEN || "";
|
||||
|
||||
const initTestExplorer = async (): Promise<void> => {
|
||||
updateUserContext({
|
||||
authorizationToken: `bearer ${authToken}`,
|
||||
@@ -41,6 +48,18 @@ const initTestExplorer = async (): Promise<void> => {
|
||||
case "tables":
|
||||
rbacToken = tableRbacToken;
|
||||
break;
|
||||
case "cassandra":
|
||||
rbacToken = cassandraRbacToken;
|
||||
break;
|
||||
case "mongo":
|
||||
rbacToken = mongoRbacToken;
|
||||
break;
|
||||
case "mongo32":
|
||||
rbacToken = mongo32RbacToken;
|
||||
break;
|
||||
case "mongo-readonly":
|
||||
rbacToken = mongoReadOnlyRbacToken;
|
||||
break;
|
||||
}
|
||||
|
||||
if (rbacToken.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user