diff --git a/less/documentDB.less b/less/documentDB.less index 00ffe2b0c..bf5f87e3e 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -2366,9 +2366,9 @@ a:link { .tabsManagerContainer { height: 100%; flex-grow: 1; - overflow: hidden; + display: flex; + flex-direction: column; min-height: 300px; - overflow-y: scroll; } .tabs { @@ -2671,7 +2671,7 @@ a:link { width: @ActiveTabWidth; } -.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper> .tabNavText { +.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText { font-weight: bolder; border-bottom: 2px solid rgba(0, 120, 212, 1); } @@ -2708,68 +2708,68 @@ a:link { border-right: @ButtonBorderWidth solid @BaseMedium; white-space: nowrap; .contentWrapper { - .flex-display(); - width: @ContentWrapper; - - .statusIconContainer { - width: @StatusIconContainerSize; - height: @StatusIconContainerSize; - margin-left: @SmallSpace; - display: inline-flex; - - .errorIconContainer { - width: @ErrorIconContainer; - height: @ErrorIconContainer; - margin-top: 1px; - - .errorIcon { - 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; + .flex-display(); + width: @ContentWrapper; + + .statusIconContainer { + width: @StatusIconContainerSize; + height: @StatusIconContainerSize; + margin-left: @SmallSpace; + display: inline-flex; + + .errorIconContainer { + width: @ErrorIconContainer; + height: @ErrorIconContainer; + margin-top: 1px; + + .errorIcon { + width: @ErrorIconWidth; height: @LoadingErrorIconSize; - margin: 0px 0px @SmallSpace @SmallSpace; + 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; } } - - .tabNavText { - margin-left: @SmallSpace; - margin-right: 2px; - color: @BaseDark; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - flex-grow: 1; + + .errorIconContainer.actionsEnabled { + &:hover { + .hover(); + } + + &:focus { + .focus(); + } + + &: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 { width: 29px; position: relative; diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index d56f5087a..ff121f3a5 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -255,6 +255,7 @@ export class HttpHeaders { public static partitionKey: string = "x-ms-documentdb-partitionkey"; public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput"; public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot"; + public static xAPIKey: string = "X-API-Key"; } export class ContentType { diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 061f25286..564e905ba 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -42,6 +42,10 @@ export interface ConfigContext { ARM_API_VERSION: string; GRAPH_ENDPOINT: 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_LIVY_ENDPOINT_DNS_ZONE: string; BACKEND_ENDPOINT?: string; @@ -93,6 +97,9 @@ let configContext: Readonly = { ARM_API_VERSION: "2016-06-01", GRAPH_ENDPOINT: "https://graph.microsoft.com", 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_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net", GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306 @@ -192,6 +199,9 @@ if (process.env.NODE_ENV === "development") { updateConfigContext({ PROXY_PATH: "/proxy", EMULATOR_ENDPOINT: "https://localhost:8081", + PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac, + MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac, + CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Mpac, }); } diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index af69452f1..04afc10bb 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -425,6 +425,7 @@ export interface SelfServeFrameInputs { authorizationToken: string; csmEndpoint: string; flights?: readonly string[]; + catalogAPIKey: string; } export class MonacoEditorSettings { diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index b20d1b78c..bb198e6c3 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -1157,21 +1157,25 @@ export default class Explorer { } public async refreshSampleData(): Promise { - if (!userContext.sampleDataConnectionInfo) { + try { + 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; } - - 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 }); } } diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index ff96d6716..320a10015 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -12,6 +12,7 @@ import * as Constants from "Common/Constants"; import { SplitterDirection } from "Common/Splitter"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { configContext } from "ConfigContext"; +import { useDatabases } from "Explorer/useDatabases"; import { DefaultRUThreshold, LocalStorageUtility, @@ -107,7 +108,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin"; const shouldShowParallelismOption = userContext.apiType !== "Gremlin"; const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled(); - const shouldShowCopilotSampleDBOption = userContext.apiType === "SQL" && useQueryCopilot.getState().copilotEnabled; + const shouldShowCopilotSampleDBOption = + userContext.apiType === "SQL" && + useQueryCopilot.getState().copilotEnabled && + useDatabases.getState().sampleDataResourceTokenCollection; const handlerOnSubmit = async () => { setIsExecuting(true); diff --git a/src/SelfServe/SelfServe.tsx b/src/SelfServe/SelfServe.tsx index f663a3ef9..932951b34 100644 --- a/src/SelfServe/SelfServe.tsx +++ b/src/SelfServe/SelfServe.tsx @@ -117,6 +117,7 @@ const handleMessage = async (event: MessageEvent): Promise => { updateConfigContext({ ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT), + CATALOG_API_KEY: inputs.catalogAPIKey, }); updateUserContext({ diff --git a/src/SelfServe/SqlX/SqlX.rp.ts b/src/SelfServe/SqlX/SqlX.rp.ts index 9cb430971..66675f7a6 100644 --- a/src/SelfServe/SqlX/SqlX.rp.ts +++ b/src/SelfServe/SqlX/SqlX.rp.ts @@ -1,11 +1,15 @@ import { configContext } from "../../ConfigContext"; import { userContext } from "../../UserContext"; -import { armRequestWithoutPolling } from "../../Utils/arm/request"; +import { get } from "../../Utils/arm/generatedClients/cosmos/locations"; +import { armRequestWithoutPolling, getOfferingIdsRequest } from "../../Utils/arm/request"; import { selfServeTraceFailure, selfServeTraceStart, selfServeTraceSuccess } from "../SelfServeTelemetryProcessor"; import { RefreshResult } from "../SelfServeTypes"; import SqlX from "./SqlX"; import { FetchPricesResponse, + GetOfferingIdsResponse, + OfferingIdMap, + OfferingIdRequest, PriceMapAndCurrencyCode, RegionItem, RegionsResponse, @@ -166,11 +170,21 @@ export const getRegions = async (): Promise> => { } }; +export const getRegionShortName = async (regionDisplayName: string): Promise => { + 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 => { return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`; }; -export const getPriceMapAndCurrencyCode = async (regions: Array): Promise => { +export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise => { const telemetryData = { feature: "Calculate approximate cost", function: "getPriceMapAndCurrencyCode", @@ -181,39 +195,94 @@ export const getPriceMapAndCurrencyCode = async (regions: Array): Pr try { const priceMap = new Map>(); - let currencyCode; - for (const regionItem of regions) { + let billingCurrency; + for (const region of map.keys()) { const regionPriceMap = new Map(); + const regionShortName = await getRegionShortName(region); + const requestBody: OfferingIdRequest = { + location: regionShortName, + ids: Array.from(map.get(region).keys()), + }; const response = await armRequestWithoutPolling({ host: configContext.ARM_ENDPOINT, path: getFetchPricesPathForRegion(userContext.subscriptionId), method: "POST", - apiVersion: "2020-01-01-preview", - queryParams: { - filter: - "armRegionName eq '" + - regionItem.locationName.split(" ").join("").toLowerCase() + - "' and serviceFamily eq 'Databases' and productName eq 'Azure Cosmos DB Dedicated Gateway - General Purpose'", - }, + apiVersion: "2023-04-01-preview", + body: requestBody, }); - for (const item of response.result.Items) { - if (currencyCode === undefined) { - currencyCode = item.currencyCode; - } else if (item.currencyCode !== currencyCode) { + 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."); } - regionPriceMap.set(item.skuName, item.retailPrice); + + 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(regionItem.locationName, regionPriceMap); + priceMap.set(region, regionPriceMap); } selfServeTraceSuccess(telemetryData, getPriceMapAndCurrencyCodeTimestamp); - return { priceMap: priceMap, currencyCode: currencyCode }; + return { priceMap: priceMap, billingCurrency: billingCurrency }; } catch (err) { const failureTelemetry = { err, selfServeClassName: SqlX.name }; selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp); - return { priceMap: undefined, currencyCode: undefined }; + return { priceMap: undefined, billingCurrency: undefined }; + } +}; + +const getOfferingIdPathForRegion = (): string => { + return `/skus?serviceFamily=Databases&service=Azure Cosmos DB`; +}; + +export const getOfferingIds = async (regions: Array): Promise => { + 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>(); + for (const regionItem of regions) { + const regionOfferingIdMap = new Map(); + const regionShortName = await getRegionShortName(regionItem.locationName); + + const response = await getOfferingIdsRequest({ + host: configContext.CATALOG_ENDPOINT, + path: getOfferingIdPathForRegion(), + method: "GET", + apiVersion: "2023-05-01-preview", + queryParams: { + filter: "armRegionName eq '" + regionShortName + "'", + }, + }); + + for (const item of response.result.items) { + if (item.offeringProperties?.length > 0) { + regionOfferingIdMap.set(item.offeringProperties[0].offeringId, item.skuName); + } + } + offeringIdMap.set(regionItem.locationName, regionOfferingIdMap); + } + + selfServeTraceSuccess(telemetryData, getOfferingIdsCodeTimestamp); + return offeringIdMap; + } catch (err) { + const failureTelemetry = { err, selfServeClassName: SqlX.name }; + selfServeTraceFailure(failureTelemetry, getOfferingIdsCodeTimestamp); + return undefined; } }; diff --git a/src/SelfServe/SqlX/SqlX.tsx b/src/SelfServe/SqlX/SqlX.tsx index 8f2954865..cbac4ef04 100644 --- a/src/SelfServe/SqlX/SqlX.tsx +++ b/src/SelfServe/SqlX/SqlX.tsx @@ -24,6 +24,7 @@ import { BladeType, generateBladeLink } from "../SelfServeUtils"; import { deleteDedicatedGatewayResource, getCurrentProvisioningState, + getOfferingIds, getPriceMapAndCurrencyCode, getRegions, refreshDedicatedGatewayProvisioning, @@ -370,9 +371,10 @@ export default class SqlX extends SelfServeBaseClass { }); regions = await getRegions(); - const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(regions); + const offeringIdMap = await getOfferingIds(regions); + const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(offeringIdMap); priceMap = priceMapAndCurrencyCode.priceMap; - currencyCode = priceMapAndCurrencyCode.currencyCode; + currencyCode = priceMapAndCurrencyCode.billingCurrency; const response = await getCurrentProvisioningState(); if (response.status && response.status !== "Deleting") { diff --git a/src/SelfServe/SqlX/SqlxTypes.ts b/src/SelfServe/SqlX/SqlxTypes.ts index 3360df734..d06b1844d 100644 --- a/src/SelfServe/SqlX/SqlxTypes.ts +++ b/src/SelfServe/SqlX/SqlxTypes.ts @@ -30,23 +30,51 @@ export type UpdateDedicatedGatewayRequestProperties = { serviceType: string; }; -export type FetchPricesResponse = { - Items: Array; - NextPageLink: string | undefined; - Count: number; +export type FetchPricesResponse = Array; + +export type PriceItem = { + prices: Array; + id: string; + billingCurrency: string; + error: PriceError; +}; + +export type PriceType = { + type: string; + unitPriceinBillingCurrency: number; +}; + +export type PriceError = { + type: string; + description: string; }; export type PriceMapAndCurrencyCode = { priceMap: Map>; - currencyCode: string; + billingCurrency: string; }; -export type PriceItem = { - retailPrice: number; - skuName: string; - currencyCode: string; +export type GetOfferingIdsResponse = { + items: Array; + nextPageLink: string | undefined; }; +export type OfferingIdItem = { + skuName: string; + offeringProperties: Array; +}; + +export type OfferingProperties = { + offeringId: string; +}; + +export type OfferingIdRequest = { + ids: Array; + location: string; +}; + +export type OfferingIdMap = Map>; + export type RegionsResponse = { properties: RegionsProperties; }; diff --git a/src/Utils/arm/request.ts b/src/Utils/arm/request.ts index 69391201b..399900ca3 100644 --- a/src/Utils/arm/request.ts +++ b/src/Utils/arm/request.ts @@ -160,3 +160,52 @@ async function getOperationStatus(operationStatusUrl: string) { } throw new Error(`Operation Response: ${JSON.stringify(body)}. Retrying.`); } + +export async function getOfferingIdsRequest({ + 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 }; +} diff --git a/test/README.md b/test/README.md index 9a3e3e622..4786f95de 100644 --- a/test/README.md +++ b/test/README.md @@ -33,9 +33,11 @@ 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: -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. Connect to your Azure account using `Connect-AzAccount`. -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: +1. This script requires Powershell 7+. Install it [here](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows). +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. +3. [Install Bicep CLI](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install#install-manually) if it is not already installed. +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 .\test\resources\create-resource-group.ps1 -SubscriptionName "My Subscription" -Location "West US 3" @@ -120,6 +122,14 @@ Configuring for E2E Testing ## 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: ```powershell @@ -140,4 +150,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. -See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests. \ No newline at end of file +See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.