mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-08 12:07:06 +00:00
Compare commits
3 Commits
hotfix/cas
...
ashleyst/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48950613a7 | ||
|
|
2bd913acbb | ||
|
|
4866d3c902 |
@@ -2366,9 +2366,9 @@ a:link {
|
|||||||
.tabsManagerContainer {
|
.tabsManagerContainer {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
flex-direction: column;
|
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
@@ -2671,7 +2671,7 @@ a:link {
|
|||||||
width: @ActiveTabWidth;
|
width: @ActiveTabWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText {
|
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper> .tabNavText {
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-bottom: 2px solid rgba(0, 120, 212, 1);
|
border-bottom: 2px solid rgba(0, 120, 212, 1);
|
||||||
}
|
}
|
||||||
@@ -2708,68 +2708,68 @@ a:link {
|
|||||||
border-right: @ButtonBorderWidth solid @BaseMedium;
|
border-right: @ButtonBorderWidth solid @BaseMedium;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
.contentWrapper {
|
.contentWrapper {
|
||||||
.flex-display();
|
.flex-display();
|
||||||
width: @ContentWrapper;
|
width: @ContentWrapper;
|
||||||
|
|
||||||
.statusIconContainer {
|
.statusIconContainer {
|
||||||
width: @StatusIconContainerSize;
|
width: @StatusIconContainerSize;
|
||||||
height: @StatusIconContainerSize;
|
height: @StatusIconContainerSize;
|
||||||
margin-left: @SmallSpace;
|
margin-left: @SmallSpace;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
.errorIconContainer {
|
.errorIconContainer {
|
||||||
width: @ErrorIconContainer;
|
width: @ErrorIconContainer;
|
||||||
height: @ErrorIconContainer;
|
height: @ErrorIconContainer;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
|
|
||||||
.errorIcon {
|
.errorIcon {
|
||||||
width: @ErrorIconWidth;
|
width: @ErrorIconWidth;
|
||||||
|
height: @LoadingErrorIconSize;
|
||||||
|
background-image: url(../images/error_no_outline.svg);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 3px;
|
||||||
|
display: block;
|
||||||
|
margin: 1px 0px 0px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorIconContainer.actionsEnabled {
|
||||||
|
&:hover {
|
||||||
|
.hover();
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
.active();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorIconContainer[tabindex]:active {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingIcon {
|
||||||
|
width: @LoadingErrorIconSize;
|
||||||
height: @LoadingErrorIconSize;
|
height: @LoadingErrorIconSize;
|
||||||
background-image: url(../images/error_no_outline.svg);
|
margin: 0px 0px @SmallSpace @SmallSpace;
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
background-size: 3px;
|
|
||||||
display: block;
|
|
||||||
margin: 1px 0px 0px 6px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorIconContainer.actionsEnabled {
|
.tabNavText {
|
||||||
&:hover {
|
margin-left: @SmallSpace;
|
||||||
.hover();
|
margin-right: 2px;
|
||||||
}
|
color: @BaseDark;
|
||||||
|
text-overflow: ellipsis;
|
||||||
&:focus {
|
overflow: hidden;
|
||||||
.focus();
|
white-space: nowrap;
|
||||||
}
|
flex-grow: 1;
|
||||||
|
|
||||||
&:active {
|
|
||||||
.active();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorIconContainer[tabindex]:active {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingIcon {
|
|
||||||
width: @LoadingErrorIconSize;
|
|
||||||
height: @LoadingErrorIconSize;
|
|
||||||
margin: 0px 0px @SmallSpace @SmallSpace;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabNavText {
|
|
||||||
margin-left: @SmallSpace;
|
|
||||||
margin-right: 2px;
|
|
||||||
color: @BaseDark;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabIconSection {
|
.tabIconSection {
|
||||||
width: 29px;
|
width: 29px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -209,7 +209,6 @@
|
|||||||
"test": "rimraf coverage && jest",
|
"test": "rimraf coverage && jest",
|
||||||
"test:debug": "jest --runInBand",
|
"test:debug": "jest --runInBand",
|
||||||
"test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
|
"test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
|
||||||
"test:file": "jest --coverage=false",
|
|
||||||
"watch": "npm run start",
|
"watch": "npm run start",
|
||||||
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
|
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
|
||||||
"build:ase": "gulp build:ase",
|
"build:ase": "gulp build:ase",
|
||||||
@@ -245,4 +244,4 @@
|
|||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"endOfLine": "auto"
|
"endOfLine": "auto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,6 @@ export enum MongoBackendEndpointType {
|
|||||||
export class BackendApi {
|
export class BackendApi {
|
||||||
public static readonly GenerateToken: string = "GenerateToken";
|
public static readonly GenerateToken: string = "GenerateToken";
|
||||||
public static readonly PortalSettings: string = "PortalSettings";
|
public static readonly PortalSettings: string = "PortalSettings";
|
||||||
public static readonly AccountRestrictions: string = "AccountRestrictions";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortalBackendEndpoints {
|
export class PortalBackendEndpoints {
|
||||||
@@ -256,7 +255,6 @@ export class HttpHeaders {
|
|||||||
public static partitionKey: string = "x-ms-documentdb-partitionkey";
|
public static partitionKey: string = "x-ms-documentdb-partitionkey";
|
||||||
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
|
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
|
||||||
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
|
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
|
||||||
public static xAPIKey: string = "X-API-Key";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ContentType {
|
export class ContentType {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||||
import Q from "q";
|
import Q from "q";
|
||||||
import * as _ from "underscore";
|
import * as _ from "underscore";
|
||||||
import * as Logger from "../Common/Logger";
|
|
||||||
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||||
import { getDataExplorerWindow } from "../Utils/WindowUtils";
|
import { getDataExplorerWindow } from "../Utils/WindowUtils";
|
||||||
import * as Constants from "./Constants";
|
import * as Constants from "./Constants";
|
||||||
@@ -97,18 +96,10 @@ const _sendMessage = (message: any): void => {
|
|||||||
const portalChildWindow = getDataExplorerWindow(window) || window;
|
const portalChildWindow = getDataExplorerWindow(window) || window;
|
||||||
if (portalChildWindow === window) {
|
if (portalChildWindow === window) {
|
||||||
// Current window is a child of portal, send message to portal window
|
// Current window is a child of portal, send message to portal window
|
||||||
if (portalChildWindow.document.referrer) {
|
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer || "*");
|
||||||
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer);
|
|
||||||
} else {
|
|
||||||
Logger.logError("Iframe failed to send message to portal", "MessageHandler");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Current window is not a child of portal, send message to the child window instead (which is data explorer)
|
// Current window is not a child of portal, send message to the child window instead (which is data explorer)
|
||||||
if (portalChildWindow.location.origin) {
|
portalChildWindow.postMessage(message, portalChildWindow.location.origin || "*");
|
||||||
portalChildWindow.postMessage(message, portalChildWindow.location.origin);
|
|
||||||
} else {
|
|
||||||
Logger.logError("Iframe failed to send message to data explorer", "MessageHandler");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CosmosClient } from "@azure/cosmos";
|
|||||||
import { sampleDataClient } from "Common/SampleDataClient";
|
import { sampleDataClient } from "Common/SampleDataClient";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import * as DataModels from "../../Contracts/DataModels";
|
import * as DataModels from "../../Contracts/DataModels";
|
||||||
|
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||||
import { client } from "../CosmosClient";
|
import { client } from "../CosmosClient";
|
||||||
import { handleError } from "../ErrorHandlingUtils";
|
import { handleError } from "../ErrorHandlingUtils";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export async function readCollectionInternal(
|
|||||||
collectionId: string,
|
collectionId: string,
|
||||||
): Promise<DataModels.Collection> {
|
): Promise<DataModels.Collection> {
|
||||||
let collection: DataModels.Collection;
|
let collection: DataModels.Collection;
|
||||||
|
const clearMessage = logConsoleProgress(`Querying container ${collectionId}`);
|
||||||
try {
|
try {
|
||||||
const response = await cosmosClient.database(databaseId).container(collectionId).read();
|
const response = await cosmosClient.database(databaseId).container(collectionId).read();
|
||||||
collection = response.resource as DataModels.Collection;
|
collection = response.resource as DataModels.Collection;
|
||||||
@@ -37,5 +39,6 @@ export async function readCollectionInternal(
|
|||||||
handleError(error, "ReadCollection", `Error while querying container ${collectionId}`);
|
handleError(error, "ReadCollection", `Error while querying container ${collectionId}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
clearMessage();
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ export interface ConfigContext {
|
|||||||
ARM_API_VERSION: string;
|
ARM_API_VERSION: string;
|
||||||
GRAPH_ENDPOINT: string;
|
GRAPH_ENDPOINT: string;
|
||||||
GRAPH_API_VERSION: string;
|
GRAPH_API_VERSION: string;
|
||||||
// This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP
|
|
||||||
CATALOG_ENDPOINT: string;
|
|
||||||
CATALOG_API_VERSION: string;
|
|
||||||
CATALOG_API_KEY: string;
|
|
||||||
ARCADIA_ENDPOINT: string;
|
ARCADIA_ENDPOINT: string;
|
||||||
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
|
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
|
||||||
BACKEND_ENDPOINT?: string;
|
BACKEND_ENDPOINT?: string;
|
||||||
@@ -97,9 +93,6 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
ARM_API_VERSION: "2016-06-01",
|
ARM_API_VERSION: "2016-06-01",
|
||||||
GRAPH_ENDPOINT: "https://graph.microsoft.com",
|
GRAPH_ENDPOINT: "https://graph.microsoft.com",
|
||||||
GRAPH_API_VERSION: "1.6",
|
GRAPH_API_VERSION: "1.6",
|
||||||
CATALOG_ENDPOINT: "https://catalogapi.azure.com/",
|
|
||||||
CATALOG_API_VERSION: "2023-05-01-preview",
|
|
||||||
CATALOG_API_KEY: "",
|
|
||||||
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
|
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
|
||||||
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
|
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
|
||||||
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
|
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
|
||||||
@@ -109,14 +102,14 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
NEW_MONGO_APIS: [
|
NEW_MONGO_APIS: [
|
||||||
// "resourcelist",
|
"resourcelist",
|
||||||
// "queryDocuments",
|
"queryDocuments",
|
||||||
// "createDocument",
|
"createDocument",
|
||||||
// "readDocument",
|
"readDocument",
|
||||||
// "updateDocument",
|
"updateDocument",
|
||||||
// "deleteDocument",
|
"deleteDocument",
|
||||||
// "createCollectionWithProxy",
|
"createCollectionWithProxy",
|
||||||
// "legacyMongoShell",
|
"legacyMongoShell",
|
||||||
],
|
],
|
||||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
||||||
@@ -199,9 +192,6 @@ if (process.env.NODE_ENV === "development") {
|
|||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
PROXY_PATH: "/proxy",
|
PROXY_PATH: "/proxy",
|
||||||
EMULATOR_ENDPOINT: "https://localhost:8081",
|
EMULATOR_ENDPOINT: "https://localhost:8081",
|
||||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
|
||||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
|
|
||||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Mpac,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +238,15 @@ export async function initializeConfiguration(): Promise<ConfigContext> {
|
|||||||
updateConfigContext({ platform });
|
updateConfigContext({ platform });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (window.location.origin !== configContext.hostedExplorerURL) {
|
||||||
|
if (window.location.origin === "https://localhost:1234") {
|
||||||
|
// Special case for localhost, we need to send them to 'hostedExplorer.html'.
|
||||||
|
updateConfigContext({ hostedExplorerURL: "https://localhost:1234/hostedExplorer.html" });
|
||||||
|
} else {
|
||||||
|
const newOrigin = window.location.origin.endsWith("/") ? window.location.origin : `${window.location.origin}/`;
|
||||||
|
updateConfigContext({ hostedExplorerURL: newOrigin });
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("No configuration file found using defaults");
|
console.error("No configuration file found using defaults");
|
||||||
}
|
}
|
||||||
@@ -255,3 +254,4 @@ export async function initializeConfiguration(): Promise<ConfigContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { configContext };
|
export { configContext };
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export interface QueryRequestOptions {
|
export interface QueryRequestOptions {
|
||||||
$skipToken?: string;
|
$skipToken?: string;
|
||||||
$top?: number;
|
$top?: number;
|
||||||
$allowPartialScopes: boolean;
|
subscriptions: string[];
|
||||||
subscriptions?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryResponse {
|
export interface QueryResponse {
|
||||||
|
|||||||
@@ -425,7 +425,6 @@ export interface SelfServeFrameInputs {
|
|||||||
authorizationToken: string;
|
authorizationToken: string;
|
||||||
csmEndpoint: string;
|
csmEndpoint: string;
|
||||||
flights?: readonly string[];
|
flights?: readonly string[];
|
||||||
catalogAPIKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MonacoEditorSettings {
|
export class MonacoEditorSettings {
|
||||||
|
|||||||
@@ -100,16 +100,6 @@ export const createCollectionContextMenuButton = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useNotebook.getState().isShellEnabled && userContext.apiType === "Cassandra") {
|
|
||||||
items.push({
|
|
||||||
iconSrc: HostedTerminalIcon,
|
|
||||||
onClick: () => {
|
|
||||||
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
|
|
||||||
},
|
|
||||||
label: "Open Cassandra Shell",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
configContext.platform !== Platform.Fabric &&
|
configContext.platform !== Platform.Fabric &&
|
||||||
(userContext.apiType === "SQL" || userContext.apiType === "Gremlin")
|
(userContext.apiType === "SQL" || userContext.apiType === "Gremlin")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DirectionalHint, Icon, Label, Stack, TooltipHost } from "@fluentui/react";
|
import { Icon, Label, Stack } from "@fluentui/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { NormalizedEventKey } from "../../../Common/Constants";
|
import { NormalizedEventKey } from "../../../Common/Constants";
|
||||||
import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
|
import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
|
||||||
@@ -8,7 +8,6 @@ export interface CollapsibleSectionProps {
|
|||||||
isExpandedByDefault: boolean;
|
isExpandedByDefault: boolean;
|
||||||
onExpand?: () => void;
|
onExpand?: () => void;
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
tooltipContent?: string | JSX.Element | JSX.Element[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollapsibleSectionState {
|
export interface CollapsibleSectionState {
|
||||||
@@ -56,19 +55,6 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
|
|||||||
>
|
>
|
||||||
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
|
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
|
||||||
<Label>{this.props.title}</Label>
|
<Label>{this.props.title}</Label>
|
||||||
{this.props.tooltipContent && (
|
|
||||||
<TooltipHost
|
|
||||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
|
||||||
content={this.props.tooltipContent}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
marginLeft: "0 !important",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
|
|
||||||
</TooltipHost>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{this.state.isExpanded && this.props.children}
|
{this.state.isExpanded && this.props.children}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.outlineNone{
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copyQuery:focus::after,
|
.copyQuery:focus::after,
|
||||||
.deleteQuery:focus::after {
|
.deleteQuery:focus::after {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
|
|||||||
<Text variant="small" aria-label="capacity calculator of azure cosmos db">
|
<Text variant="small" aria-label="capacity calculator of azure cosmos db">
|
||||||
Estimate your required RU/s with{" "}
|
Estimate your required RU/s with{" "}
|
||||||
<Link
|
<Link
|
||||||
className="underlinedLink outlineNone"
|
className="underlinedLink"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://cosmos.azure.com/capacitycalculator/"
|
href="https://cosmos.azure.com/capacitycalculator/"
|
||||||
aria-label="capacity calculator of azure cosmos db"
|
aria-label="capacity calculator of azure cosmos db"
|
||||||
|
|||||||
@@ -733,13 +733,13 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
|
|||||||
|
|
||||||
<StyledLinkBase
|
<StyledLinkBase
|
||||||
aria-label="capacity calculator of azure cosmos db"
|
aria-label="capacity calculator of azure cosmos db"
|
||||||
className="underlinedLink outlineNone"
|
className="underlinedLink"
|
||||||
href="https://cosmos.azure.com/capacitycalculator/"
|
href="https://cosmos.azure.com/capacitycalculator/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<LinkBase
|
<LinkBase
|
||||||
aria-label="capacity calculator of azure cosmos db"
|
aria-label="capacity calculator of azure cosmos db"
|
||||||
className="underlinedLink outlineNone"
|
className="underlinedLink"
|
||||||
href="https://cosmos.azure.com/capacitycalculator/"
|
href="https://cosmos.azure.com/capacitycalculator/"
|
||||||
styles={[Function]}
|
styles={[Function]}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1019,7 +1019,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
aria-label="capacity calculator of azure cosmos db"
|
aria-label="capacity calculator of azure cosmos db"
|
||||||
className="ms-Link underlinedLink outlineNone root-117"
|
className="ms-Link underlinedLink root-117"
|
||||||
href="https://cosmos.azure.com/capacitycalculator/"
|
href="https://cosmos.azure.com/capacitycalculator/"
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1157,25 +1157,21 @@ export default class Explorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async refreshSampleData(): Promise<void> {
|
public async refreshSampleData(): Promise<void> {
|
||||||
try {
|
if (!userContext.sampleDataConnectionInfo) {
|
||||||
if (!userContext.sampleDataConnectionInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const collection: DataModels.Collection = await readSampleCollection();
|
|
||||||
if (!collection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const databaseId = userContext.sampleDataConnectionInfo?.databaseId;
|
|
||||||
if (!databaseId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleDataResourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection, true);
|
|
||||||
useDatabases.setState({ sampleDataResourceTokenCollection });
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "Explorer");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collection: DataModels.Collection = await readSampleCollection();
|
||||||
|
if (!collection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseId = userContext.sampleDataConnectionInfo?.databaseId;
|
||||||
|
if (!databaseId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleDataResourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection, true);
|
||||||
|
useDatabases.setState({ sampleDataResourceTokenCollection });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const addRootChildToGraph = (
|
|||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
export const escapeDoubleQuotes = (value: string): string => {
|
export const escapeDoubleQuotes = (value: string): string => {
|
||||||
return value === undefined ? value : value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
return value === undefined ? value : value.replace(/"/g, '\\"');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,5 +186,5 @@ export const getQuotedPropValue = (ip: ViewModels.InputPropertyValue): string =>
|
|||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
export const escapeSingleQuotes = (value: string): string => {
|
export const escapeSingleQuotes = (value: string): string => {
|
||||||
return value === undefined ? value : value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
return value === undefined ? value : value.replace(/'/g, "\\'");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,25 +143,6 @@ export function createContextCommandBarButtons(
|
|||||||
buttons.push(newMongoShellBtn);
|
buttons.push(newMongoShellBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
useNotebook.getState().isShellEnabled &&
|
|
||||||
!selectedNodeState.isDatabaseNodeOrNoneSelected() &&
|
|
||||||
userContext.apiType === "Cassandra"
|
|
||||||
) {
|
|
||||||
const label: string = "Open Cassandra Shell";
|
|
||||||
const newCassandraShellButton: CommandButtonComponentProps = {
|
|
||||||
iconSrc: HostedTerminalIcon,
|
|
||||||
iconAlt: label,
|
|
||||||
onCommandClick: () => {
|
|
||||||
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
|
|
||||||
},
|
|
||||||
commandButtonLabel: label,
|
|
||||||
ariaLabel: label,
|
|
||||||
hasPopup: true,
|
|
||||||
};
|
|
||||||
buttons.push(newCassandraShellButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buttons;
|
return buttons;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,43 @@
|
|||||||
import { PrimaryButton, Stack, Text } from "@fluentui/react";
|
import { PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||||
|
import { AuthType } from "AuthType";
|
||||||
|
import { configContext } from "ConfigContext";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export const OpenFullScreen: React.FunctionComponent = () => {
|
export const OpenFullScreen: React.FunctionComponent = () => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.append("openFrom", "portal");
|
||||||
|
|
||||||
|
let hasAccountContext = false;
|
||||||
|
let requiresConnectionString = false;
|
||||||
|
|
||||||
|
if (userContext.authType === AuthType.AAD) {
|
||||||
|
if (userContext.subscriptionId && userContext.databaseAccount) {
|
||||||
|
searchParams.append("subscription", userContext.subscriptionId);
|
||||||
|
searchParams.append("account", userContext.databaseAccount.name);
|
||||||
|
searchParams.append("authType", "entra");
|
||||||
|
hasAccountContext = true;
|
||||||
|
}
|
||||||
|
} else if (userContext.authType === AuthType.MasterKey || userContext.authType === AuthType.ResourceToken) {
|
||||||
|
requiresConnectionString = true;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: "34px" }}>
|
<div style={{ padding: "34px" }}>
|
||||||
<Stack tokens={{ childrenGap: 10 }}>
|
<Stack tokens={{ childrenGap: 10 }}>
|
||||||
<Text>
|
<Text>
|
||||||
Open this database account in a new browser tab with Cosmos DB Explorer. You can connect using your
|
Open this database account in a new browser tab with Cosmos DB Explorer.
|
||||||
Microsoft account or a connection string.
|
{requiresConnectionString && " You'll need to provide a connection string."}
|
||||||
|
{hasAccountContext && " You may be prompted to sign in with Entra ID again."}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
Open tabs and queries will not be carried over, but you can still access them in this tab.
|
||||||
</Text>
|
</Text>
|
||||||
<Stack horizontal tokens={{ childrenGap: 10 }}>
|
<Stack horizontal tokens={{ childrenGap: 10 }}>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
href={`${configContext.hostedExplorerURL}?${searchParams.toString()}`}
|
||||||
window.open("https://cosmos.azure.com/", "_blank");
|
target="_blank"
|
||||||
}}
|
|
||||||
text="Open"
|
text="Open"
|
||||||
iconProps={{ iconName: "OpenInNewWindow" }}
|
iconProps={{ iconName: "OpenInNewWindow" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
|||||||
import { configContext, Platform } from "ConfigContext";
|
import { configContext, Platform } from "ConfigContext";
|
||||||
import * as DataModels from "Contracts/DataModels";
|
import * as DataModels from "Contracts/DataModels";
|
||||||
import { SubscriptionType } from "Contracts/SubscriptionType";
|
import { SubscriptionType } from "Contracts/SubscriptionType";
|
||||||
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
|
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||||
import { useSidePanel } from "hooks/useSidePanel";
|
import { useSidePanel } from "hooks/useSidePanel";
|
||||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -82,6 +82,22 @@ export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
|
|||||||
excludedPaths: [],
|
excludedPaths: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DefaultDatabaseVectorIndex: DataModels.IndexingPolicy = {
|
||||||
|
indexingMode: "consistent",
|
||||||
|
automatic: true,
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: [
|
||||||
|
{
|
||||||
|
path: '/"_etag"/?',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vectorIndexes: [],
|
||||||
|
};
|
||||||
|
|
||||||
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
|
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
|
||||||
vectorEmbeddings: [],
|
vectorEmbeddings: [],
|
||||||
};
|
};
|
||||||
@@ -106,9 +122,8 @@ export interface AddCollectionPanelState {
|
|||||||
isExecuting: boolean;
|
isExecuting: boolean;
|
||||||
isThroughputCapExceeded: boolean;
|
isThroughputCapExceeded: boolean;
|
||||||
teachingBubbleStep: number;
|
teachingBubbleStep: number;
|
||||||
vectorIndexingPolicy: DataModels.VectorIndex[];
|
vectorIndexingPolicy: string;
|
||||||
vectorEmbeddingPolicy: DataModels.VectorEmbedding[];
|
vectorEmbeddingPolicy: string;
|
||||||
vectorPolicyValidated: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AddCollectionPanel extends React.Component<AddCollectionPanelProps, AddCollectionPanelState> {
|
export class AddCollectionPanel extends React.Component<AddCollectionPanelProps, AddCollectionPanelState> {
|
||||||
@@ -144,9 +159,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
isExecuting: false,
|
isExecuting: false,
|
||||||
isThroughputCapExceeded: false,
|
isThroughputCapExceeded: false,
|
||||||
teachingBubbleStep: 0,
|
teachingBubbleStep: 0,
|
||||||
vectorEmbeddingPolicy: [],
|
vectorIndexingPolicy: JSON.stringify(DefaultDatabaseVectorIndex, null, 2),
|
||||||
vectorIndexingPolicy: [],
|
vectorEmbeddingPolicy: JSON.stringify(DefaultVectorEmbeddingPolicy, null, 2),
|
||||||
vectorPolicyValidated: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +590,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
</TooltipHost>
|
</TooltipHost>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Text variant="small">{this.getPartitionKeySubtext()}</Text>
|
<Text variant="small" aria-label="pkDescription">
|
||||||
|
{this.getPartitionKeySubtext()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -880,28 +896,60 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
)}
|
)}
|
||||||
{this.shouldShowVectorSearchParameters() && (
|
{this.shouldShowVectorSearchParameters() && (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<CollapsibleSectionComponent
|
||||||
|
title="Indexing Policy"
|
||||||
|
isExpandedByDefault={false}
|
||||||
|
onExpand={() => {
|
||||||
|
this.scrollToSection("collapsibleVectorPolicySectionContent");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
||||||
|
<Link href="https://aka.ms/CosmosDBVectorSetup" target="_blank">
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
<EditorReact
|
||||||
|
language={"json"}
|
||||||
|
content={this.state.vectorIndexingPolicy}
|
||||||
|
isReadOnly={false}
|
||||||
|
wordWrap={"on"}
|
||||||
|
ariaLabel={"Editing indexing policy"}
|
||||||
|
lineNumbers={"on"}
|
||||||
|
scrollBeyondLastLine={false}
|
||||||
|
spinnerClassName="panelSectionSpinner"
|
||||||
|
monacoContainerStyles={{
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
onContentChanged={(newIndexingPolicy: string) => this.setVectorIndexingPolicy(newIndexingPolicy)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</CollapsibleSectionComponent>
|
||||||
<CollapsibleSectionComponent
|
<CollapsibleSectionComponent
|
||||||
title="Container Vector Policy"
|
title="Container Vector Policy"
|
||||||
isExpandedByDefault={false}
|
isExpandedByDefault={false}
|
||||||
onExpand={() => {
|
onExpand={() => {
|
||||||
this.scrollToSection("collapsibleVectorPolicySectionContent");
|
this.scrollToSection("collapsibleVectorPolicySectionContent");
|
||||||
}}
|
}}
|
||||||
tooltipContent={this.getContainerVectorPolicyTooltipContent()}
|
|
||||||
>
|
>
|
||||||
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
||||||
<Stack styles={{ root: { paddingLeft: 40 } }}>
|
<Link href="https://aka.ms/CosmosDBVectorSetup" target="_blank">
|
||||||
<AddVectorEmbeddingPolicyForm
|
Learn more
|
||||||
vectorEmbedding={this.state.vectorEmbeddingPolicy}
|
</Link>
|
||||||
vectorIndex={this.state.vectorIndexingPolicy}
|
<EditorReact
|
||||||
onVectorEmbeddingChange={(
|
language={"json"}
|
||||||
vectorEmbeddingPolicy: DataModels.VectorEmbedding[],
|
content={this.state.vectorEmbeddingPolicy}
|
||||||
vectorIndexingPolicy: DataModels.VectorIndex[],
|
isReadOnly={false}
|
||||||
vectorPolicyValidated: boolean,
|
wordWrap={"on"}
|
||||||
) => {
|
ariaLabel={"Editing container vector policy"}
|
||||||
this.setState({ vectorEmbeddingPolicy, vectorIndexingPolicy, vectorPolicyValidated });
|
lineNumbers={"on"}
|
||||||
}}
|
scrollBeyondLastLine={false}
|
||||||
/>
|
spinnerClassName="panelSectionSpinner"
|
||||||
</Stack>
|
monacoContainerStyles={{
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
onContentChanged={(newVectorEmbeddingPolicy: string) =>
|
||||||
|
this.setVectorEmbeddingPolicy(newVectorEmbeddingPolicy)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</CollapsibleSectionComponent>
|
</CollapsibleSectionComponent>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1111,13 +1159,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: DataModels.VectorEmbedding[]): void {
|
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: string): void {
|
||||||
this.setState({
|
this.setState({
|
||||||
vectorEmbeddingPolicy,
|
vectorEmbeddingPolicy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setVectorIndexingPolicy(vectorIndexingPolicy: DataModels.VectorIndex[]): void {
|
private setVectorIndexingPolicy(vectorIndexingPolicy: string): void {
|
||||||
this.setState({
|
this.setState({
|
||||||
vectorIndexingPolicy,
|
vectorIndexingPolicy,
|
||||||
});
|
});
|
||||||
@@ -1203,18 +1251,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getContainerVectorPolicyTooltipContent(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<Text variant="small">
|
|
||||||
Describe any properties in your data that contain vectors, so that they can be made available for similarity
|
|
||||||
queries.{" "}
|
|
||||||
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
|
|
||||||
Learn more
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldShowCollectionThroughputInput(): boolean {
|
private shouldShowCollectionThroughputInput(): boolean {
|
||||||
if (isServerlessAccount()) {
|
if (isServerlessAccount()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1334,9 +1370,20 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
|
if (this.shouldShowVectorSearchParameters()) {
|
||||||
this.setState({ errorMessage: "Please fix errors in container vector policy" });
|
try {
|
||||||
return false;
|
JSON.parse(this.state.vectorIndexingPolicy) as DataModels.IndexingPolicy;
|
||||||
|
} catch (e) {
|
||||||
|
this.setState({ errorMessage: "Invalid JSON format for indexingPolicy" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(this.state.vectorEmbeddingPolicy) as DataModels.VectorEmbeddingPolicy;
|
||||||
|
} catch (e) {
|
||||||
|
this.setState({ errorMessage: "Invalid JSON format for vectorEmbeddingPolicy" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -1414,17 +1461,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing
|
let indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing
|
||||||
? AllPropertiesIndexed
|
? AllPropertiesIndexed
|
||||||
: SharedDatabaseDefault;
|
: SharedDatabaseDefault;
|
||||||
|
|
||||||
let vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
|
let vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
|
||||||
|
|
||||||
if (this.shouldShowVectorSearchParameters()) {
|
if (this.shouldShowVectorSearchParameters()) {
|
||||||
indexingPolicy.vectorIndexes = this.state.vectorIndexingPolicy;
|
indexingPolicy = JSON.parse(this.state.vectorIndexingPolicy);
|
||||||
vectorEmbeddingPolicy = {
|
vectorEmbeddingPolicy = JSON.parse(this.state.vectorEmbeddingPolicy);
|
||||||
vectorEmbeddings: this.state.vectorEmbeddingPolicy,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const telemetryData = {
|
const telemetryData = {
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
</TooltipHost>
|
</TooltipHost>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Text variant="small">
|
<Text variant="small" aria-label="pkDescription">
|
||||||
{getPartitionKeySubtext(userContext.features.partitionKeyDefault, userContext.apiType)}
|
{getPartitionKeySubtext(userContext.features.partitionKeyDefault, userContext.apiType)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import * as Constants from "Common/Constants";
|
|||||||
import { SplitterDirection } from "Common/Splitter";
|
import { SplitterDirection } from "Common/Splitter";
|
||||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||||
import { configContext } from "ConfigContext";
|
import { configContext } from "ConfigContext";
|
||||||
import { useDatabases } from "Explorer/useDatabases";
|
|
||||||
import {
|
import {
|
||||||
DefaultRUThreshold,
|
DefaultRUThreshold,
|
||||||
LocalStorageUtility,
|
LocalStorageUtility,
|
||||||
@@ -108,10 +107,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin";
|
const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin";
|
||||||
const shouldShowParallelismOption = userContext.apiType !== "Gremlin";
|
const shouldShowParallelismOption = userContext.apiType !== "Gremlin";
|
||||||
const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled();
|
const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled();
|
||||||
const shouldShowCopilotSampleDBOption =
|
const shouldShowCopilotSampleDBOption = userContext.apiType === "SQL" && useQueryCopilot.getState().copilotEnabled;
|
||||||
userContext.apiType === "SQL" &&
|
|
||||||
useQueryCopilot.getState().copilotEnabled &&
|
|
||||||
useDatabases.getState().sampleDataResourceTokenCollection;
|
|
||||||
const handlerOnSubmit = async () => {
|
const handlerOnSubmit = async () => {
|
||||||
setIsExecuting(true);
|
setIsExecuting(true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/extend-expect";
|
|
||||||
import { RenderResult, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
|
|
||||||
import React from "react";
|
|
||||||
import { AddVectorEmbeddingPolicyForm } from "./AddVectorEmbeddingPolicyForm";
|
|
||||||
|
|
||||||
const mockVectorEmbedding: VectorEmbedding[] = [
|
|
||||||
{ path: "/vector1", dataType: "float32", distanceFunction: "euclidean", dimensions: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockVectorIndex: VectorIndex[] = [{ path: "/vector1", type: "flat" }];
|
|
||||||
|
|
||||||
const mockOnVectorEmbeddingChange = jest.fn();
|
|
||||||
|
|
||||||
describe("AddVectorEmbeddingPolicyForm", () => {
|
|
||||||
let component: RenderResult;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
component = render(
|
|
||||||
<AddVectorEmbeddingPolicyForm
|
|
||||||
vectorEmbedding={mockVectorEmbedding}
|
|
||||||
vectorIndex={mockVectorIndex}
|
|
||||||
onVectorEmbeddingChange={mockOnVectorEmbeddingChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly", () => {
|
|
||||||
expect(screen.getByText("Vector embedding 1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByPlaceholderText("/vector1")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls onVectorEmbeddingChange on adding a new vector embedding", () => {
|
|
||||||
fireEvent.click(screen.getByText("Add vector embedding"));
|
|
||||||
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls onDelete when delete button is clicked", async () => {
|
|
||||||
const deleteButton = component.container.querySelector("#delete-vector-policy-1");
|
|
||||||
fireEvent.click(deleteButton);
|
|
||||||
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
|
|
||||||
expect(screen.queryByText("Vector embedding 1")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls onVectorEmbeddingPathChange on input change", () => {
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/newPath" } });
|
|
||||||
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validates input correctly", async () => {
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "" } });
|
|
||||||
await waitFor(() => expect(screen.getByText("Vector embedding path should not be empty")).toBeInTheDocument(), {
|
|
||||||
timeout: 1500,
|
|
||||||
});
|
|
||||||
await waitFor(
|
|
||||||
() =>
|
|
||||||
expect(
|
|
||||||
screen.getByText("Vector embedding dimension must be greater than 0 and less than or equal 4096"),
|
|
||||||
).toBeInTheDocument(),
|
|
||||||
{
|
|
||||||
timeout: 1500,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
fireEvent.change(component.container.querySelector("#vector-policy-dimension-1"), { target: { value: "4096" } });
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/vector1" } });
|
|
||||||
await waitFor(() => expect(screen.queryByText("Vector embedding path should not be empty")).toBeNull(), {
|
|
||||||
timeout: 1500,
|
|
||||||
});
|
|
||||||
await waitFor(
|
|
||||||
() => expect(screen.queryByText("Maximum allowed dimension for flat index is 505")).toBeInTheDocument(),
|
|
||||||
{
|
|
||||||
timeout: 1500,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duplicate vector path is not allowed", async () => {
|
|
||||||
fireEvent.click(screen.getByText("Add vector embedding"));
|
|
||||||
fireEvent.change(component.container.querySelector("#vector-policy-path-2"), { target: { value: "/vector1" } });
|
|
||||||
await waitFor(() => expect(screen.queryByText("Vector embedding path is already defined")).toBeNull(), {
|
|
||||||
timeout: 1500,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import {
|
|
||||||
DefaultButton,
|
|
||||||
Dropdown,
|
|
||||||
IDropdownOption,
|
|
||||||
IStyleFunctionOrObject,
|
|
||||||
ITextFieldStyleProps,
|
|
||||||
ITextFieldStyles,
|
|
||||||
IconButton,
|
|
||||||
Label,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
|
|
||||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
|
||||||
import {
|
|
||||||
getDataTypeOptions,
|
|
||||||
getDistanceFunctionOptions,
|
|
||||||
getIndexTypeOptions,
|
|
||||||
} from "Explorer/Panes/VectorSearchPanel/VectorSearchUtils";
|
|
||||||
import React, { FunctionComponent, useState } from "react";
|
|
||||||
|
|
||||||
export interface IAddVectorEmbeddingPolicyFormProps {
|
|
||||||
vectorEmbedding: VectorEmbedding[];
|
|
||||||
vectorIndex: VectorIndex[];
|
|
||||||
onVectorEmbeddingChange: (
|
|
||||||
vectorEmbeddings: VectorEmbedding[],
|
|
||||||
vectorIndexingPolicies: VectorIndex[],
|
|
||||||
validationPassed: boolean,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VectorEmbeddingPolicyData {
|
|
||||||
path: string;
|
|
||||||
dataType: VectorEmbedding["dataType"];
|
|
||||||
distanceFunction: VectorEmbedding["distanceFunction"];
|
|
||||||
dimensions: number;
|
|
||||||
indexType: VectorIndex["type"] | "none";
|
|
||||||
pathError: string;
|
|
||||||
dimensionsError: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
|
|
||||||
|
|
||||||
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
|
|
||||||
fieldGroup: {
|
|
||||||
height: 27,
|
|
||||||
},
|
|
||||||
field: {
|
|
||||||
fontSize: 12,
|
|
||||||
padding: "0 8px",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownStyles = {
|
|
||||||
title: {
|
|
||||||
height: 27,
|
|
||||||
lineHeight: "24px",
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
dropdown: {
|
|
||||||
height: 27,
|
|
||||||
lineHeight: "24px",
|
|
||||||
},
|
|
||||||
dropdownItem: {
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AddVectorEmbeddingPolicyForm: FunctionComponent<IAddVectorEmbeddingPolicyFormProps> = ({
|
|
||||||
vectorEmbedding,
|
|
||||||
vectorIndex,
|
|
||||||
onVectorEmbeddingChange,
|
|
||||||
}): JSX.Element => {
|
|
||||||
const onVectorEmbeddingPathError = (path: string, index?: number): string => {
|
|
||||||
let error = "";
|
|
||||||
if (!path) {
|
|
||||||
error = "Vector embedding path should not be empty";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
index >= 0 &&
|
|
||||||
vectorEmbeddingPolicyData?.find(
|
|
||||||
(vectorEmbedding: VectorEmbeddingPolicyData, dataIndex: number) =>
|
|
||||||
dataIndex !== index && vectorEmbedding.path === path,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
error = "Vector embedding path is already defined";
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => {
|
|
||||||
let error = "";
|
|
||||||
if (dimension <= 0 || dimension > 4096) {
|
|
||||||
error = "Vector embedding dimension must be greater than 0 and less than or equal 4096";
|
|
||||||
}
|
|
||||||
if (indexType === "flat" && dimension > 505) {
|
|
||||||
error = "Maximum allowed dimension for flat index is 505";
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initializeData = (vectorEmbedding: VectorEmbedding[], vectorIndex: VectorIndex[]) => {
|
|
||||||
const mergedData: VectorEmbeddingPolicyData[] = [];
|
|
||||||
vectorEmbedding.forEach((embedding) => {
|
|
||||||
const matchingIndex = vectorIndex.find((index) => index.path === embedding.path);
|
|
||||||
mergedData.push({
|
|
||||||
...embedding,
|
|
||||||
indexType: matchingIndex?.type || "none",
|
|
||||||
pathError: onVectorEmbeddingPathError(embedding.path),
|
|
||||||
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return mergedData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [vectorEmbeddingPolicyData, setVectorEmbeddingPolicyData] = useState<VectorEmbeddingPolicyData[]>(
|
|
||||||
initializeData(vectorEmbedding, vectorIndex),
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
propagateData();
|
|
||||||
}, [vectorEmbeddingPolicyData]);
|
|
||||||
|
|
||||||
const propagateData = () => {
|
|
||||||
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({
|
|
||||||
dataType: policy.dataType,
|
|
||||||
dimensions: policy.dimensions,
|
|
||||||
distanceFunction: policy.distanceFunction,
|
|
||||||
path: policy.path,
|
|
||||||
}));
|
|
||||||
const vectorIndexingPolicies: VectorIndex[] = vectorEmbeddingPolicyData
|
|
||||||
.filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none")
|
|
||||||
.map(
|
|
||||||
(policy) =>
|
|
||||||
({
|
|
||||||
path: policy.path,
|
|
||||||
type: policy.indexType,
|
|
||||||
}) as VectorIndex,
|
|
||||||
);
|
|
||||||
const validationPassed = vectorEmbeddingPolicyData.every(
|
|
||||||
(policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "",
|
|
||||||
);
|
|
||||||
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexingPolicies, validationPassed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVectorEmbeddingPathChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.target.value.trim();
|
|
||||||
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
|
|
||||||
if (!vectorEmbeddings[index]?.path && !value.startsWith("/")) {
|
|
||||||
vectorEmbeddings[index].path = "/" + value;
|
|
||||||
} else {
|
|
||||||
vectorEmbeddings[index].path = value;
|
|
||||||
}
|
|
||||||
const error = onVectorEmbeddingPathError(value, index);
|
|
||||||
vectorEmbeddings[index].pathError = error;
|
|
||||||
setVectorEmbeddingPolicyData(vectorEmbeddings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVectorEmbeddingDimensionsChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = parseInt(event.target.value.trim()) || 0;
|
|
||||||
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
|
|
||||||
const vectorEmbedding = vectorEmbeddings[index];
|
|
||||||
vectorEmbeddings[index].dimensions = value;
|
|
||||||
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType);
|
|
||||||
vectorEmbeddings[index].dimensionsError = error;
|
|
||||||
setVectorEmbeddingPolicyData(vectorEmbeddings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVectorEmbeddingIndexTypeChange = (index: number, option: IDropdownOption): void => {
|
|
||||||
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
|
|
||||||
const vectorEmbedding = vectorEmbeddings[index];
|
|
||||||
vectorEmbeddings[index].indexType = option.key as never;
|
|
||||||
const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType);
|
|
||||||
vectorEmbeddings[index].dimensionsError = error;
|
|
||||||
setVectorEmbeddingPolicyData(vectorEmbeddings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVectorEmbeddingPolicyChange = (
|
|
||||||
index: number,
|
|
||||||
option: IDropdownOption,
|
|
||||||
property: VectorEmbeddingPolicyProperty,
|
|
||||||
): void => {
|
|
||||||
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
|
|
||||||
vectorEmbeddings[index][property] = option.key as never;
|
|
||||||
setVectorEmbeddingPolicyData(vectorEmbeddings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAdd = () => {
|
|
||||||
setVectorEmbeddingPolicyData([
|
|
||||||
...vectorEmbeddingPolicyData,
|
|
||||||
{
|
|
||||||
path: "",
|
|
||||||
dataType: "float32",
|
|
||||||
distanceFunction: "euclidean",
|
|
||||||
dimensions: 0,
|
|
||||||
indexType: "none",
|
|
||||||
pathError: onVectorEmbeddingPathError(""),
|
|
||||||
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete = (index: number) => {
|
|
||||||
const vectorEmbeddings = vectorEmbeddingPolicyData.filter((_uniqueKey, j) => index !== j);
|
|
||||||
setVectorEmbeddingPolicyData(vectorEmbeddings);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 4 }}>
|
|
||||||
{vectorEmbeddingPolicyData.length > 0 &&
|
|
||||||
vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => (
|
|
||||||
<CollapsibleSectionComponent key={index} isExpandedByDefault={true} title={`Vector embedding ${index + 1}`}>
|
|
||||||
<Stack horizontal tokens={{ childrenGap: 4 }}>
|
|
||||||
<Stack
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
margin: "0 0 6px 20px !important",
|
|
||||||
paddingLeft: 20,
|
|
||||||
width: "80%",
|
|
||||||
borderLeft: "1px solid",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Label styles={{ root: { fontSize: 12 } }}>Path</Label>
|
|
||||||
<TextField
|
|
||||||
id={`vector-policy-path-${index + 1}`}
|
|
||||||
required={true}
|
|
||||||
placeholder="/vector1"
|
|
||||||
styles={textFieldStyles}
|
|
||||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onVectorEmbeddingPathChange(index, event)}
|
|
||||||
value={vectorEmbeddingPolicy.path || ""}
|
|
||||||
errorMessage={vectorEmbeddingPolicy.pathError}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Label styles={{ root: { fontSize: 12 } }}>Data type</Label>
|
|
||||||
<Dropdown
|
|
||||||
required={true}
|
|
||||||
styles={dropdownStyles}
|
|
||||||
options={getDataTypeOptions()}
|
|
||||||
selectedKey={vectorEmbeddingPolicy.dataType}
|
|
||||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
|
||||||
onVectorEmbeddingPolicyChange(index, option, "dataType")
|
|
||||||
}
|
|
||||||
></Dropdown>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Label styles={{ root: { fontSize: 12 } }}>Distance function</Label>
|
|
||||||
<Dropdown
|
|
||||||
required={true}
|
|
||||||
styles={dropdownStyles}
|
|
||||||
options={getDistanceFunctionOptions()}
|
|
||||||
selectedKey={vectorEmbeddingPolicy.distanceFunction}
|
|
||||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
|
||||||
onVectorEmbeddingPolicyChange(index, option, "distanceFunction")
|
|
||||||
}
|
|
||||||
></Dropdown>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Label styles={{ root: { fontSize: 12 } }}>Dimensions</Label>
|
|
||||||
<TextField
|
|
||||||
id={`vector-policy-dimension-${index + 1}`}
|
|
||||||
required={true}
|
|
||||||
styles={textFieldStyles}
|
|
||||||
value={String(vectorEmbeddingPolicy.dimensions || 0)}
|
|
||||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
onVectorEmbeddingDimensionsChange(index, event)
|
|
||||||
}
|
|
||||||
errorMessage={vectorEmbeddingPolicy.dimensionsError}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Label styles={{ root: { fontSize: 12 } }}>Index type</Label>
|
|
||||||
<Dropdown
|
|
||||||
required={true}
|
|
||||||
styles={dropdownStyles}
|
|
||||||
options={getIndexTypeOptions()}
|
|
||||||
selectedKey={vectorEmbeddingPolicy.indexType}
|
|
||||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
|
||||||
onVectorEmbeddingIndexTypeChange(index, option)
|
|
||||||
}
|
|
||||||
></Dropdown>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<IconButton
|
|
||||||
id={`delete-vector-policy-${index + 1}`}
|
|
||||||
iconProps={{ iconName: "Delete" }}
|
|
||||||
style={{ height: 27, margin: "auto" }}
|
|
||||||
onClick={() => onDelete(index)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</CollapsibleSectionComponent>
|
|
||||||
))}
|
|
||||||
<DefaultButton id={`add-vector-policy`} styles={{ root: { maxWidth: 170, fontSize: 12 } }} onClick={onAdd}>
|
|
||||||
Add vector embedding
|
|
||||||
</DefaultButton>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { IDropdownOption } from "@fluentui/react";
|
|
||||||
|
|
||||||
const dataTypes = ["float32", "uint8", "int8"];
|
|
||||||
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
|
|
||||||
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
|
|
||||||
|
|
||||||
export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(dataTypes);
|
|
||||||
export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions);
|
|
||||||
export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes);
|
|
||||||
|
|
||||||
function createDropdownOptionsFromLiterals<T extends string>(literals: T[]): IDropdownOption[] {
|
|
||||||
return literals.map((value) => ({
|
|
||||||
key: value,
|
|
||||||
text: value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -223,6 +223,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
|||||||
</StyledTooltipHostBase>
|
</StyledTooltipHostBase>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text
|
<Text
|
||||||
|
aria-label="pkDescription"
|
||||||
variant="small"
|
variant="small"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -504,12 +504,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
{!showFeedbackBar && (
|
{!showFeedbackBar && (
|
||||||
<Text style={{ fontSize: 12 }}>
|
<Text style={{ fontSize: 12 }}>
|
||||||
AI-generated content can have mistakes. Make sure it's accurate and appropriate before using it.{" "}
|
AI-generated content can have mistakes. Make sure it's accurate and appropriate before using it.{" "}
|
||||||
<Link
|
<Link href="https://aka.ms/cdb-copilot-preview-terms" target="_blank" style={{ color: "#0072D4" }}>
|
||||||
href="https://aka.ms/cdb-copilot-preview-terms"
|
|
||||||
target="_blank"
|
|
||||||
style={{ color: "#0072D4" }}
|
|
||||||
className="underlinedLink"
|
|
||||||
>
|
|
||||||
Read preview terms
|
Read preview terms
|
||||||
</Link>
|
</Link>
|
||||||
{showErrorMessageBar && (
|
{showErrorMessageBar && (
|
||||||
|
|||||||
@@ -355,15 +355,15 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
|
|||||||
) : (
|
) : (
|
||||||
<div className="moreStuffContainer">
|
<div className="moreStuffContainer">
|
||||||
<div className="moreStuffColumn commonTasks">
|
<div className="moreStuffColumn commonTasks">
|
||||||
<h2 className="title">Recents</h2>
|
<div className="title">Recents</div>
|
||||||
{this.getRecentItems()}
|
{this.getRecentItems()}
|
||||||
</div>
|
</div>
|
||||||
<div className="moreStuffColumn">
|
<div className="moreStuffColumn">
|
||||||
<h2 className="title">Top 3 things you need to know</h2>
|
<div className="title">Top 3 things you need to know</div>
|
||||||
{this.top3Items()}
|
{this.top3Items()}
|
||||||
</div>
|
</div>
|
||||||
<div className="moreStuffColumn tipsContainer">
|
<div className="moreStuffColumn tipsContainer">
|
||||||
<h2 className="title">Learning Resources</h2>
|
<div className="title">Learning Resources</div>
|
||||||
{this.getLearningResourceItems()}
|
{this.getLearningResourceItems()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,24 +363,18 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||||||
entitiesToDelete: Entities.ITableEntity[],
|
entitiesToDelete: Entities.ITableEntity[],
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const query = `DELETE FROM ${collection.databaseId}.${collection.id()} WHERE `;
|
const query = `DELETE FROM ${collection.databaseId}.${collection.id()} WHERE `;
|
||||||
const partitionKeys: CassandraTableKey[] = collection.cassandraKeys.partitionKeys;
|
const partitionKeyProperty = this.getCassandraPartitionKeyProperty(collection);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entitiesToDelete.map(async (currEntityToDelete: Entities.ITableEntity) => {
|
entitiesToDelete.map(async (currEntityToDelete: Entities.ITableEntity) => {
|
||||||
const clearMessage = NotificationConsoleUtils.logConsoleProgress(`Deleting row ${currEntityToDelete.RowKey._}`);
|
const clearMessage = NotificationConsoleUtils.logConsoleProgress(`Deleting row ${currEntityToDelete.RowKey._}`);
|
||||||
|
const partitionKeyValue = currEntityToDelete[partitionKeyProperty];
|
||||||
|
const currQuery =
|
||||||
|
query +
|
||||||
|
(this.isStringType(partitionKeyValue.$)
|
||||||
|
? `${partitionKeyProperty} = '${partitionKeyValue._}'`
|
||||||
|
: `${partitionKeyProperty} = ${partitionKeyValue._}`);
|
||||||
|
|
||||||
let currQuery = query;
|
|
||||||
for (let partitionKeyIndex = 0; partitionKeyIndex < partitionKeys.length; partitionKeyIndex++) {
|
|
||||||
const partitionKey: CassandraTableKey = partitionKeys[partitionKeyIndex];
|
|
||||||
const partitionKeyValue: Entities.ITableEntityAttribute = currEntityToDelete[partitionKey.property];
|
|
||||||
currQuery =
|
|
||||||
currQuery +
|
|
||||||
(this.isStringType(partitionKeyValue.$)
|
|
||||||
? `${partitionKey.property} = '${partitionKeyValue._}'`
|
|
||||||
: `${partitionKey.property} = ${partitionKeyValue._}`);
|
|
||||||
if (partitionKeyIndex < partitionKeys.length - 1) {
|
|
||||||
currQuery = `${currQuery} AND `;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await this.queryDocuments(collection, currQuery);
|
await this.queryDocuments(collection, currQuery);
|
||||||
NotificationConsoleUtils.logConsoleInfo(`Successfully deleted row ${currEntityToDelete.RowKey._}`);
|
NotificationConsoleUtils.logConsoleInfo(`Successfully deleted row ${currEntityToDelete.RowKey._}`);
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
isPreferredApiMongoDB: false,
|
isPreferredApiMongoDB: false,
|
||||||
documentIds: [],
|
documentIds: [],
|
||||||
collection: undefined,
|
collection: undefined,
|
||||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
partitionKey: undefined,
|
||||||
onLoadStartKey: 0,
|
onLoadStartKey: 0,
|
||||||
tabTitle: "",
|
tabTitle: "",
|
||||||
onExecutionErrorChange: (isExecutionError: boolean): void => {
|
onExecutionErrorChange: (isExecutionError: boolean): void => {
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
|||||||
import MongoUtility from "Common/MongoUtility";
|
import MongoUtility from "Common/MongoUtility";
|
||||||
import { StyleConstants } from "Common/StyleConstants";
|
import { StyleConstants } from "Common/StyleConstants";
|
||||||
import { createDocument } from "Common/dataAccess/createDocument";
|
import { createDocument } from "Common/dataAccess/createDocument";
|
||||||
import {
|
import { deleteDocuments as deleteNoSqlDocuments } from "Common/dataAccess/deleteDocument";
|
||||||
deleteDocument as deleteNoSqlDocument,
|
|
||||||
deleteDocuments as deleteNoSqlDocuments,
|
|
||||||
} from "Common/dataAccess/deleteDocument";
|
|
||||||
import { queryDocuments } from "Common/dataAccess/queryDocuments";
|
import { queryDocuments } from "Common/dataAccess/queryDocuments";
|
||||||
import { readDocument } from "Common/dataAccess/readDocument";
|
import { readDocument } from "Common/dataAccess/readDocument";
|
||||||
import { updateDocument } from "Common/dataAccess/updateDocument";
|
import { updateDocument } from "Common/dataAccess/updateDocument";
|
||||||
@@ -827,7 +824,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
|
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implementation using bulk delete NoSQL API
|
* Implementation using bulk delete
|
||||||
*/
|
*/
|
||||||
let _deleteDocuments = useCallback(
|
let _deleteDocuments = useCallback(
|
||||||
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||||
@@ -837,14 +834,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
tabTitle,
|
tabTitle,
|
||||||
});
|
});
|
||||||
setIsExecuting(true);
|
setIsExecuting(true);
|
||||||
|
return deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||||
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
|
|
||||||
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
|
|
||||||
return (
|
|
||||||
partitionKey.systemKey
|
|
||||||
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
|
||||||
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
|
||||||
)
|
|
||||||
.then(
|
.then(
|
||||||
(deletedIds) => {
|
(deletedIds) => {
|
||||||
TelemetryProcessor.traceSuccess(
|
TelemetryProcessor.traceSuccess(
|
||||||
@@ -1810,8 +1800,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
size={tableContainerSizePx}
|
size={tableContainerSizePx}
|
||||||
columnHeaders={columnHeaders}
|
columnHeaders={columnHeaders}
|
||||||
isSelectionDisabled={
|
isSelectionDisabled={
|
||||||
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{tableItems.length > 0 && (
|
{tableItems.length > 0 && (
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default class MongoShellTabComponent extends Component<
|
|||||||
data: {
|
data: {
|
||||||
resourceId: resourceId,
|
resourceId: resourceId,
|
||||||
accountName: accountName,
|
accountName: accountName,
|
||||||
mongoEndpoint: this._useMongoProxyEndpoint ? documentEndpoint : mongoEndpoint,
|
mongoEndpoint: mongoEndpoint,
|
||||||
authorization: authorization,
|
authorization: authorization,
|
||||||
databaseId: databaseId,
|
databaseId: databaseId,
|
||||||
collectionId: collectionId,
|
collectionId: collectionId,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const App: React.FunctionComponent = () => {
|
|||||||
// For showing/hiding panel
|
// For showing/hiding panel
|
||||||
const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false);
|
const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false);
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const { isLoggedIn, armToken, graphToken, account, tenantId, logout, login, switchTenant, authFailure } =
|
const { isLoggedIn, armToken, graphToken, account, tenantId, logout, forgetUser, login, switchTenant, authFailure } =
|
||||||
useAADAuth();
|
useAADAuth();
|
||||||
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
|
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
|
||||||
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
|
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import "../less/index.less";
|
|||||||
|
|
||||||
const Index = (): JSX.Element => {
|
const Index = (): JSX.Element => {
|
||||||
const [navigationSelection, setNavigationSelection] = useState("quickstart");
|
const [navigationSelection, setNavigationSelection] = useState("quickstart");
|
||||||
|
|
||||||
const quickstart_click = () => {
|
const quickstart_click = () => {
|
||||||
setNavigationSelection("quickstart");
|
setNavigationSelection("quickstart");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
|
|
||||||
import { DefaultButton, IButtonStyles, IContextualMenuItem } from "@fluentui/react";
|
import { DefaultButton, IButtonStyles, IContextualMenuItem } from "@fluentui/react";
|
||||||
|
import { urlContext } from "Utils/UrlContext";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent, useEffect, useState } from "react";
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
import { StyleConstants } from "../../../Common/StyleConstants";
|
import { StyleConstants } from "../../../Common/StyleConstants";
|
||||||
@@ -50,12 +51,12 @@ interface Props {
|
|||||||
export const AccountSwitcher: FunctionComponent<Props> = ({ armToken, setDatabaseAccount }: Props) => {
|
export const AccountSwitcher: FunctionComponent<Props> = ({ armToken, setDatabaseAccount }: Props) => {
|
||||||
const subscriptions = useSubscriptions(armToken);
|
const subscriptions = useSubscriptions(armToken);
|
||||||
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<string>(() =>
|
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<string>(() =>
|
||||||
localStorage.getItem("cachedSubscriptionId"),
|
urlContext.subscription || localStorage.getItem("cachedSubscriptionId"),
|
||||||
);
|
);
|
||||||
const selectedSubscription = subscriptions?.find((sub) => sub.subscriptionId === selectedSubscriptionId);
|
const selectedSubscription = subscriptions?.find((sub) => sub.subscriptionId === selectedSubscriptionId);
|
||||||
const accounts = useDatabaseAccounts(selectedSubscription?.subscriptionId, armToken);
|
const accounts = useDatabaseAccounts(selectedSubscription?.subscriptionId, armToken);
|
||||||
const [selectedAccountName, setSelectedAccountName] = useState<string>(() =>
|
const [selectedAccountName, setSelectedAccountName] = useState<string>(() =>
|
||||||
localStorage.getItem("cachedDatabaseAccountName"),
|
urlContext.account || localStorage.getItem("cachedDatabaseAccountName"),
|
||||||
);
|
);
|
||||||
const selectedAccount = accounts?.find((account) => account.name === selectedAccountName);
|
const selectedAccount = accounts?.find((account) => account.name === selectedAccountName);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useBoolean } from "@fluentui/react-hooks";
|
import { useBoolean } from "@fluentui/react-hooks";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
||||||
|
import { OpenedFrom, RequestedAuthType, urlContext } from "Utils/UrlContext";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
|
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
|
||||||
import ErrorImage from "../../../../images/error.svg";
|
import ErrorImage from "../../../../images/error.svg";
|
||||||
@@ -51,18 +52,13 @@ export const fetchEncryptedToken_ToBeDeprecated = async (connectionString: strin
|
|||||||
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
|
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.append(HttpHeaders.connectionString, connectionString);
|
headers.append(HttpHeaders.connectionString, connectionString);
|
||||||
|
const url = configContext.BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
||||||
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings)
|
|
||||||
? configContext.PORTAL_BACKEND_ENDPOINT
|
|
||||||
: configContext.BACKEND_ENDPOINT;
|
|
||||||
|
|
||||||
const url = backendEndpoint + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
|
||||||
const response = await fetch(url, { headers, method: "POST" });
|
const response = await fetch(url, { headers, method: "POST" });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw response;
|
throw response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.text()).toLowerCase() === "true";
|
return (await response.text()) === "True";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||||
@@ -74,7 +70,9 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||||
const [errorMessage, setErrorMessage] = React.useState("");
|
const [errorMessage, setErrorMessage] = React.useState("");
|
||||||
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
|
const enableConnectionStringLogin =
|
||||||
|
urlContext.authType !== RequestedAuthType.Entra &&
|
||||||
|
!userContext.features.disableConnectionStringLogin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="connectExplorer" className="connectExplorerContainer" style={{ display: "flex" }}>
|
<div id="connectExplorer" className="connectExplorerContainer" style={{ display: "flex" }}>
|
||||||
@@ -141,6 +139,11 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
<p className="switchConnectTypeText" data-test="Link:SwitchConnectionType" onClick={showForm}>
|
<p className="switchConnectTypeText" data-test="Link:SwitchConnectionType" onClick={showForm}>
|
||||||
Connect to your account with connection string
|
Connect to your account with connection string
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{urlContext.openFrom === OpenedFrom.Portal && (
|
||||||
|
<p className="switchConnectTypeText">
|
||||||
|
Click sign in to continue your session from the Azure Portal.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ const handleMessage = async (event: MessageEvent): Promise<void> => {
|
|||||||
|
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT),
|
ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT),
|
||||||
CATALOG_API_KEY: inputs.catalogAPIKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
updateUserContext({
|
updateUserContext({
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { configContext } from "../../ConfigContext";
|
import { configContext } from "../../ConfigContext";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
import { get } from "../../Utils/arm/generatedClients/cosmos/locations";
|
import { armRequestWithoutPolling } from "../../Utils/arm/request";
|
||||||
import { armRequestWithoutPolling, getOfferingIdsRequest } from "../../Utils/arm/request";
|
|
||||||
import { selfServeTraceFailure, selfServeTraceStart, selfServeTraceSuccess } from "../SelfServeTelemetryProcessor";
|
import { selfServeTraceFailure, selfServeTraceStart, selfServeTraceSuccess } from "../SelfServeTelemetryProcessor";
|
||||||
import { RefreshResult } from "../SelfServeTypes";
|
import { RefreshResult } from "../SelfServeTypes";
|
||||||
import SqlX from "./SqlX";
|
import SqlX from "./SqlX";
|
||||||
import {
|
import {
|
||||||
FetchPricesResponse,
|
FetchPricesResponse,
|
||||||
GetOfferingIdsResponse,
|
|
||||||
OfferingIdMap,
|
|
||||||
OfferingIdRequest,
|
|
||||||
PriceMapAndCurrencyCode,
|
PriceMapAndCurrencyCode,
|
||||||
RegionItem,
|
RegionItem,
|
||||||
RegionsResponse,
|
RegionsResponse,
|
||||||
@@ -170,21 +166,11 @@ export const getRegions = async (): Promise<Array<RegionItem>> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRegionShortName = async (regionDisplayName: string): Promise<string> => {
|
|
||||||
const locationsList = await get(userContext.subscriptionId, regionDisplayName);
|
|
||||||
|
|
||||||
if ("id" in locationsList) {
|
|
||||||
const locationId = locationsList.id;
|
|
||||||
return locationId.substring(locationId.lastIndexOf("/") + 1);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFetchPricesPathForRegion = (subscriptionId: string): string => {
|
const getFetchPricesPathForRegion = (subscriptionId: string): string => {
|
||||||
return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`;
|
return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise<PriceMapAndCurrencyCode> => {
|
export const getPriceMapAndCurrencyCode = async (regions: Array<RegionItem>): Promise<PriceMapAndCurrencyCode> => {
|
||||||
const telemetryData = {
|
const telemetryData = {
|
||||||
feature: "Calculate approximate cost",
|
feature: "Calculate approximate cost",
|
||||||
function: "getPriceMapAndCurrencyCode",
|
function: "getPriceMapAndCurrencyCode",
|
||||||
@@ -195,94 +181,39 @@ export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise<Pr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const priceMap = new Map<string, Map<string, number>>();
|
const priceMap = new Map<string, Map<string, number>>();
|
||||||
let billingCurrency;
|
let currencyCode;
|
||||||
for (const region of map.keys()) {
|
for (const regionItem of regions) {
|
||||||
const regionPriceMap = new Map<string, number>();
|
const regionPriceMap = new Map<string, number>();
|
||||||
const regionShortName = await getRegionShortName(region);
|
|
||||||
const requestBody: OfferingIdRequest = {
|
|
||||||
location: regionShortName,
|
|
||||||
ids: Array.from(map.get(region).keys()),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await armRequestWithoutPolling<FetchPricesResponse>({
|
const response = await armRequestWithoutPolling<FetchPricesResponse>({
|
||||||
host: configContext.ARM_ENDPOINT,
|
host: configContext.ARM_ENDPOINT,
|
||||||
path: getFetchPricesPathForRegion(userContext.subscriptionId),
|
path: getFetchPricesPathForRegion(userContext.subscriptionId),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
apiVersion: "2023-04-01-preview",
|
apiVersion: "2020-01-01-preview",
|
||||||
body: requestBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const item of response.result) {
|
|
||||||
if (item.error) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingCurrency === undefined) {
|
|
||||||
billingCurrency = item.billingCurrency;
|
|
||||||
} else if (item.billingCurrency !== billingCurrency) {
|
|
||||||
throw Error("Currency Code Mismatch: Currency code not same for all regions / skus.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const offeringId = item.id;
|
|
||||||
const skuName = map.get(region).get(offeringId);
|
|
||||||
const unitPriceinBillingCurrency = item.prices.find((x) => x.type === "Consumption")
|
|
||||||
?.unitPriceinBillingCurrency;
|
|
||||||
regionPriceMap.set(skuName, unitPriceinBillingCurrency);
|
|
||||||
}
|
|
||||||
priceMap.set(region, regionPriceMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
selfServeTraceSuccess(telemetryData, getPriceMapAndCurrencyCodeTimestamp);
|
|
||||||
return { priceMap: priceMap, billingCurrency: billingCurrency };
|
|
||||||
} catch (err) {
|
|
||||||
const failureTelemetry = { err, selfServeClassName: SqlX.name };
|
|
||||||
selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp);
|
|
||||||
return { priceMap: undefined, billingCurrency: undefined };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOfferingIdPathForRegion = (): string => {
|
|
||||||
return `/skus?serviceFamily=Databases&service=Azure Cosmos DB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOfferingIds = async (regions: Array<RegionItem>): Promise<OfferingIdMap> => {
|
|
||||||
const telemetryData = {
|
|
||||||
feature: "Get Offering Ids to calculate approximate cost",
|
|
||||||
function: "getOfferingIds",
|
|
||||||
description: "fetch offering ids API call",
|
|
||||||
selfServeClassName: SqlX.name,
|
|
||||||
};
|
|
||||||
const getOfferingIdsCodeTimestamp = selfServeTraceStart(telemetryData);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const offeringIdMap = new Map<string, Map<string, string>>();
|
|
||||||
for (const regionItem of regions) {
|
|
||||||
const regionOfferingIdMap = new Map<string, string>();
|
|
||||||
const regionShortName = await getRegionShortName(regionItem.locationName);
|
|
||||||
|
|
||||||
const response = await getOfferingIdsRequest<GetOfferingIdsResponse>({
|
|
||||||
host: configContext.CATALOG_ENDPOINT,
|
|
||||||
path: getOfferingIdPathForRegion(),
|
|
||||||
method: "GET",
|
|
||||||
apiVersion: "2023-05-01-preview",
|
|
||||||
queryParams: {
|
queryParams: {
|
||||||
filter: "armRegionName eq '" + regionShortName + "'",
|
filter:
|
||||||
|
"armRegionName eq '" +
|
||||||
|
regionItem.locationName.split(" ").join("").toLowerCase() +
|
||||||
|
"' and serviceFamily eq 'Databases' and productName eq 'Azure Cosmos DB Dedicated Gateway - General Purpose'",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const item of response.result.items) {
|
for (const item of response.result.Items) {
|
||||||
if (item.offeringProperties?.length > 0) {
|
if (currencyCode === undefined) {
|
||||||
regionOfferingIdMap.set(item.offeringProperties[0].offeringId, item.skuName);
|
currencyCode = item.currencyCode;
|
||||||
|
} else if (item.currencyCode !== currencyCode) {
|
||||||
|
throw Error("Currency Code Mismatch: Currency code not same for all regions / skus.");
|
||||||
}
|
}
|
||||||
|
regionPriceMap.set(item.skuName, item.retailPrice);
|
||||||
}
|
}
|
||||||
offeringIdMap.set(regionItem.locationName, regionOfferingIdMap);
|
priceMap.set(regionItem.locationName, regionPriceMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
selfServeTraceSuccess(telemetryData, getOfferingIdsCodeTimestamp);
|
selfServeTraceSuccess(telemetryData, getPriceMapAndCurrencyCodeTimestamp);
|
||||||
return offeringIdMap;
|
return { priceMap: priceMap, currencyCode: currencyCode };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failureTelemetry = { err, selfServeClassName: SqlX.name };
|
const failureTelemetry = { err, selfServeClassName: SqlX.name };
|
||||||
selfServeTraceFailure(failureTelemetry, getOfferingIdsCodeTimestamp);
|
selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp);
|
||||||
return undefined;
|
return { priceMap: undefined, currencyCode: undefined };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { BladeType, generateBladeLink } from "../SelfServeUtils";
|
|||||||
import {
|
import {
|
||||||
deleteDedicatedGatewayResource,
|
deleteDedicatedGatewayResource,
|
||||||
getCurrentProvisioningState,
|
getCurrentProvisioningState,
|
||||||
getOfferingIds,
|
|
||||||
getPriceMapAndCurrencyCode,
|
getPriceMapAndCurrencyCode,
|
||||||
getRegions,
|
getRegions,
|
||||||
refreshDedicatedGatewayProvisioning,
|
refreshDedicatedGatewayProvisioning,
|
||||||
@@ -371,10 +370,9 @@ export default class SqlX extends SelfServeBaseClass {
|
|||||||
});
|
});
|
||||||
|
|
||||||
regions = await getRegions();
|
regions = await getRegions();
|
||||||
const offeringIdMap = await getOfferingIds(regions);
|
const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(regions);
|
||||||
const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(offeringIdMap);
|
|
||||||
priceMap = priceMapAndCurrencyCode.priceMap;
|
priceMap = priceMapAndCurrencyCode.priceMap;
|
||||||
currencyCode = priceMapAndCurrencyCode.billingCurrency;
|
currencyCode = priceMapAndCurrencyCode.currencyCode;
|
||||||
|
|
||||||
const response = await getCurrentProvisioningState();
|
const response = await getCurrentProvisioningState();
|
||||||
if (response.status && response.status !== "Deleting") {
|
if (response.status && response.status !== "Deleting") {
|
||||||
|
|||||||
@@ -30,51 +30,23 @@ export type UpdateDedicatedGatewayRequestProperties = {
|
|||||||
serviceType: string;
|
serviceType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FetchPricesResponse = Array<PriceItem>;
|
export type FetchPricesResponse = {
|
||||||
|
Items: Array<PriceItem>;
|
||||||
export type PriceItem = {
|
NextPageLink: string | undefined;
|
||||||
prices: Array<PriceType>;
|
Count: number;
|
||||||
id: string;
|
|
||||||
billingCurrency: string;
|
|
||||||
error: PriceError;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PriceType = {
|
|
||||||
type: string;
|
|
||||||
unitPriceinBillingCurrency: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PriceError = {
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PriceMapAndCurrencyCode = {
|
export type PriceMapAndCurrencyCode = {
|
||||||
priceMap: Map<string, Map<string, number>>;
|
priceMap: Map<string, Map<string, number>>;
|
||||||
billingCurrency: string;
|
currencyCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetOfferingIdsResponse = {
|
export type PriceItem = {
|
||||||
items: Array<OfferingIdItem>;
|
retailPrice: number;
|
||||||
nextPageLink: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OfferingIdItem = {
|
|
||||||
skuName: string;
|
skuName: string;
|
||||||
offeringProperties: Array<OfferingProperties>;
|
currencyCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OfferingProperties = {
|
|
||||||
offeringId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OfferingIdRequest = {
|
|
||||||
ids: Array<string>;
|
|
||||||
location: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OfferingIdMap = Map<string, Map<string, string>>;
|
|
||||||
|
|
||||||
export type RegionsResponse = {
|
export type RegionsResponse = {
|
||||||
properties: RegionsProperties;
|
properties: RegionsProperties;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import { CollectionCreation, CollectionCreationDefaults } from "./Shared/Constan
|
|||||||
interface ThroughputDefaults {
|
interface ThroughputDefaults {
|
||||||
fixed: number;
|
fixed: number;
|
||||||
unlimited:
|
unlimited:
|
||||||
| number
|
| number
|
||||||
| {
|
| {
|
||||||
collectionThreshold: number;
|
collectionThreshold: number;
|
||||||
lessThanOrEqualToThreshold: number;
|
lessThanOrEqualToThreshold: number;
|
||||||
greatThanThreshold: number;
|
greatThanThreshold: number;
|
||||||
};
|
};
|
||||||
unlimitedmax: number;
|
unlimitedmax: number;
|
||||||
unlimitedmin: number;
|
unlimitedmin: number;
|
||||||
shared: number;
|
shared: number;
|
||||||
@@ -192,3 +192,4 @@ function apiType(account: DatabaseAccount | undefined): ApiType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { updateUserContext, userContext };
|
export { updateUserContext, userContext };
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const allowedGraphEndpoints: ReadonlyArray<string> = ["https://graph.micr
|
|||||||
|
|
||||||
export const allowedArcadiaEndpoints: ReadonlyArray<string> = ["https://workspaceartifacts.projectarcadia.net"];
|
export const allowedArcadiaEndpoints: ReadonlyArray<string> = ["https://workspaceartifacts.projectarcadia.net"];
|
||||||
|
|
||||||
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = ["https://cosmos.azure.com/"];
|
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = ["https://cosmos.azure.com/", "https://localhost:1234/"];
|
||||||
|
|
||||||
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = [
|
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = [
|
||||||
"https://cosmos-explorer-preview.azurewebsites.net/",
|
"https://cosmos-explorer-preview.azurewebsites.net/",
|
||||||
@@ -164,7 +164,6 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
|
|||||||
PortalBackendEndpoints.Mpac,
|
PortalBackendEndpoints.Mpac,
|
||||||
PortalBackendEndpoints.Prod,
|
PortalBackendEndpoints.Prod,
|
||||||
],
|
],
|
||||||
[BackendApi.AccountRestrictions]: [PortalBackendEndpoints.Development, PortalBackendEndpoints.Mpac],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {
|
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {
|
||||||
|
|||||||
17
src/Utils/UrlContext.ts
Normal file
17
src/Utils/UrlContext.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
export enum OpenedFrom {
|
||||||
|
Portal = "portal",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RequestedAuthType {
|
||||||
|
Entra = "entra",
|
||||||
|
ConnectionString = "connectionstring",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const urlContext = {
|
||||||
|
openFrom: urlParams.get("openFrom") as OpenedFrom,
|
||||||
|
authType: urlParams.get("authType") as RequestedAuthType,
|
||||||
|
subscription: urlParams.get("subscription"),
|
||||||
|
account: urlParams.get("account"),
|
||||||
|
};
|
||||||
@@ -160,52 +160,3 @@ async function getOperationStatus(operationStatusUrl: string) {
|
|||||||
}
|
}
|
||||||
throw new Error(`Operation Response: ${JSON.stringify(body)}. Retrying.`);
|
throw new Error(`Operation Response: ${JSON.stringify(body)}. Retrying.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOfferingIdsRequest<T>({
|
|
||||||
host,
|
|
||||||
path,
|
|
||||||
apiVersion,
|
|
||||||
method,
|
|
||||||
body: requestBody,
|
|
||||||
queryParams,
|
|
||||||
}: Options): Promise<{ result: T; operationStatusUrl: string }> {
|
|
||||||
const url = new URL(path, host);
|
|
||||||
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
|
|
||||||
if (queryParams) {
|
|
||||||
queryParams.filter && url.searchParams.append("$filter", queryParams.filter);
|
|
||||||
queryParams.metricNames && url.searchParams.append("metricnames", queryParams.metricNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!configContext.CATALOG_API_KEY) {
|
|
||||||
throw new Error("No catalog API key provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await window.fetch(url.href, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
[HttpHeaders.xAPIKey]: configContext.CATALOG_API_KEY,
|
|
||||||
},
|
|
||||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
let error: ARMError;
|
|
||||||
try {
|
|
||||||
const errorResponse = (await response.json()) as ParsedErrorResponse;
|
|
||||||
if ("error" in errorResponse) {
|
|
||||||
error = new ARMError(errorResponse.error.message);
|
|
||||||
error.code = errorResponse.error.code;
|
|
||||||
} else {
|
|
||||||
error = new ARMError(errorResponse.message);
|
|
||||||
error.code = errorResponse.code;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationStatusUrl = (response.headers && response.headers.get("location")) || "";
|
|
||||||
const responseBody = (await response.json()) as T;
|
|
||||||
return { result: responseBody, operationStatusUrl: operationStatusUrl };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function useAADAuth(): ReturnType {
|
|||||||
const [authFailure, setAuthFailure] = React.useState<AadAuthFailure>(undefined);
|
const [authFailure, setAuthFailure] = React.useState<AadAuthFailure>(undefined);
|
||||||
|
|
||||||
msalInstance.setActiveAccount(account);
|
msalInstance.setActiveAccount(account);
|
||||||
|
|
||||||
const login = React.useCallback(async () => {
|
const login = React.useCallback(async () => {
|
||||||
const response = await msalInstance.loginPopup({
|
const response = await msalInstance.loginPopup({
|
||||||
redirectUri: configContext.msalRedirectURI,
|
redirectUri: configContext.msalRedirectURI,
|
||||||
@@ -47,13 +48,13 @@ export function useAADAuth(): ReturnType {
|
|||||||
setAccount(response.account);
|
setAccount(response.account);
|
||||||
setTenantId(response.tenantId);
|
setTenantId(response.tenantId);
|
||||||
localStorage.setItem("cachedTenantId", response.tenantId);
|
localStorage.setItem("cachedTenantId", response.tenantId);
|
||||||
}, []);
|
}, [setLoggedIn, setAccount, setTenantId]);
|
||||||
|
|
||||||
const logout = React.useCallback(() => {
|
const logout = React.useCallback(() => {
|
||||||
setLoggedOut();
|
setLoggedOut();
|
||||||
localStorage.removeItem("cachedTenantId");
|
localStorage.removeItem("cachedTenantId");
|
||||||
msalInstance.logoutRedirect();
|
msalInstance.logoutRedirect();
|
||||||
}, []);
|
}, [setLoggedOut]);
|
||||||
|
|
||||||
const switchTenant = React.useCallback(
|
const switchTenant = React.useCallback(
|
||||||
async (id) => {
|
async (id) => {
|
||||||
@@ -66,7 +67,7 @@ export function useAADAuth(): ReturnType {
|
|||||||
setAccount(response.account);
|
setAccount(response.account);
|
||||||
localStorage.setItem("cachedTenantId", response.tenantId);
|
localStorage.setItem("cachedTenantId", response.tenantId);
|
||||||
},
|
},
|
||||||
[account, tenantId],
|
[setTenantId, setAccount],
|
||||||
);
|
);
|
||||||
|
|
||||||
const acquireTokens = React.useCallback(async () => {
|
const acquireTokens = React.useCallback(async () => {
|
||||||
@@ -123,6 +124,19 @@ export function useAADAuth(): ReturnType {
|
|||||||
}
|
}
|
||||||
}, [account, tenantId, acquireTokens, authFailure]);
|
}, [account, tenantId, acquireTokens, authFailure]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
// If we're on a redirect, handle it
|
||||||
|
const response = await msalInstance.handleRedirectPromise();
|
||||||
|
if (response) {
|
||||||
|
setLoggedIn();
|
||||||
|
setAccount(response.account);
|
||||||
|
setTenantId(response.tenantId);
|
||||||
|
localStorage.setItem("cachedTenantId", response.tenantId);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [setLoggedIn])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|||||||
@@ -52,17 +52,11 @@ export async function fetchDatabaseAccountsFromGraph(
|
|||||||
const body = {
|
const body = {
|
||||||
query: databaseAccountsQuery,
|
query: databaseAccountsQuery,
|
||||||
subscriptions: [subscriptionId],
|
subscriptions: [subscriptionId],
|
||||||
...(skipToken
|
...(skipToken && {
|
||||||
? {
|
options: {
|
||||||
options: {
|
$skipToken: skipToken,
|
||||||
$skipToken: skipToken,
|
} as QueryRequestOptions,
|
||||||
} as QueryRequestOptions,
|
}),
|
||||||
}
|
|
||||||
: {
|
|
||||||
options: {
|
|
||||||
$top: 150,
|
|
||||||
} as QueryRequestOptions,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(managementResourceGraphAPIURL, {
|
const response = await fetch(managementResourceGraphAPIURL, {
|
||||||
|
|||||||
@@ -51,13 +51,11 @@ export async function fetchSubscriptionsFromGraph(accessToken: string): Promise<
|
|||||||
do {
|
do {
|
||||||
const body = {
|
const body = {
|
||||||
query: subscriptionsQuery,
|
query: subscriptionsQuery,
|
||||||
options: {
|
...(skipToken && {
|
||||||
$allowPartialScopes: true,
|
options: {
|
||||||
$top: 150,
|
|
||||||
...(skipToken && {
|
|
||||||
$skipToken: skipToken,
|
$skipToken: skipToken,
|
||||||
}),
|
} as QueryRequestOptions,
|
||||||
} as QueryRequestOptions,
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(managementResourceGraphAPIURL, {
|
const response = await fetch(managementResourceGraphAPIURL, {
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ All you need to provide is a resource group to deploy in to.
|
|||||||
|
|
||||||
To use this script, there are a few prerequisites that must be done at least once:
|
To use this script, there are a few prerequisites that must be done at least once:
|
||||||
|
|
||||||
1. This script requires Powershell 7+. Install it [here](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows).
|
1. [Install Azure PowerShell](https://learn.microsoft.com/en-us/powershell/azure/install-azps-windows?view=azps-12.0.0&tabs=powershell&pivots=windows-psgallery) if you don't already have it.
|
||||||
2. [Install Azure PowerShell](https://learn.microsoft.com/en-us/powershell/azure/install-azps-windows?view=azps-12.0.0&tabs=powershell&pivots=windows-psgallery) if you don't already have it.
|
2. Connect to your Azure account using `Connect-AzAccount`.
|
||||||
3. [Install Bicep CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install#install-manually) if it is not already installed.
|
3. Ensure you have a Resource Group _ready_ to deploy into, the deploy script requires an existing resource group. This resource group should be named `[username]-e2e-testing`, where `[username]` is your Windows username, (**Microsoft employees:** This should be your alias). The easiest way to do this is by running the `create-resource-group.ps1` script, specifying the Subscription (Name or ID) and Location in which you want to create the Resource Group. For example:
|
||||||
4. Connect to your Azure account using `Connect-AzAccount`.
|
|
||||||
5. Ensure you have a Resource Group _ready_ to deploy into, the deploy script requires an existing resource group. This resource group should be named `[username]-e2e-testing`, where `[username]` is your Windows username, (**Microsoft employees:** This should be your alias). The easiest way to do this is by running the `create-resource-group.ps1` script, specifying the Subscription (Name or ID) and Location in which you want to create the Resource Group. For example:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\test\resources\create-resource-group.ps1 -SubscriptionName "My Subscription" -Location "West US 3"
|
.\test\resources\create-resource-group.ps1 -SubscriptionName "My Subscription" -Location "West US 3"
|
||||||
@@ -122,14 +120,6 @@ Configuring for E2E Testing
|
|||||||
|
|
||||||
## Running the tests
|
## Running the tests
|
||||||
|
|
||||||
If Azure CLI is not installed, please [install it](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli).
|
|
||||||
|
|
||||||
Log into Az CLI with the following command:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
az login --scope https://management.core.windows.net//.default
|
|
||||||
```
|
|
||||||
|
|
||||||
To run all tests in a headless browser, run the following command from the root of the repo:
|
To run all tests in a headless browser, run the following command from the root of the repo:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -150,4 +140,4 @@ npx playwright test --ui
|
|||||||
|
|
||||||
The UI allows you to select a specific test to run and to see the results of the test in the browser.
|
The UI allows you to select a specific test to run and to see the results of the test in the browser.
|
||||||
|
|
||||||
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
|
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
|
||||||
Reference in New Issue
Block a user