diff --git a/src/Localization/en/translations.json b/src/Localization/en/translations.json index f10775900..44d4506cd 100644 --- a/src/Localization/en/translations.json +++ b/src/Localization/en/translations.json @@ -36,6 +36,57 @@ "SubmissionMessageErrorText": "Data update failed because of errors.", "OnSaveFailureMessage": "Data save operation not currently permitted." }, - "SqlX": {} + "SqlX": { + "DedicatedGatewayDescription": "Provision a dedicated gateway cluster for your Azure Cosmos DB account. A dedicated gateway is compute that is a front-end to data in your Azure Cosmos DB account. Your dedicated gateway automatically includes the integrated cache, which can improve read performance. ", + "DedicatedGateway": "Dedicated Gateway", + "Enable": "Enable", + "Disable": "Disable", + "LearnAboutDedicatedGateway": "Learn more about dedicated gateway.", + "DeprovisioningDetailsText": "Learn more about deprovisioning the dedicated gateway.", + "DedicatedGatewayPricing": "Learn more about dedicated gateway pricing", + "SKUs": "SKUs", + "SKUsPlaceHolder": "Select SKUs", + "NumberOfInstances": "Number of instances", + "CosmosD4s": "Cosmos.D4s (General Purpose Cosmos Compute with 4 vCPUs, 16 GB Memory)", + "CosmosD8s": "Cosmos.D8s (General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory)", + "CosmosD16s": "Cosmos.D16s (General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory)", + "CosmosD32s": "Cosmos.D32s (General Purpose Cosmos Compute with 32 vCPUs, 128 GB Memory)", + "CreateMessage": "Dedicated gateway resource is being created.", + "CreateInitializeTitle": "Provisioning resource", + "CreateInitializeMessage": "Dedicated gateway resource will be provisioned.", + "CreateSuccessTitle": "Resource provisioned", + "CreateSuccesseMessage": "Dedicated gateway resource provisioned.", + "CreateFailureTitle": "Failed to provision resource", + "CreateFailureMessage": "Dedicated gateway resource provisioning failed.", + "UpdateMessage": "Dedicated gateway resource is being updated.", + "UpdateInitializeTitle": "Updating resource", + "UpdateInitializeMessage": "Dedicated gateway resource will be updated.", + "UpdateSuccessTitle": "Resource updated", + "UpdateSuccesseMessage": "Dedicated gateway resource updated.", + "UpdateFailureTitle": "Failed to update resource", + "UpdateFailureMessage": "Dedicated gateway resource updation failed.", + "DeleteMessage": "Dedicated gateway resource is being deleted.", + "DeleteInitializeTitle": "Deleting resource", + "DeleteInitializeMessage": "Dedicated gateway resource will be deleted.", + "DeleteSuccessTitle": "Resource deleted", + "DeleteSuccesseMessage": "Dedicated gateway resource deleted.", + "DeleteFailureTitle": "Failed to delete resource", + "DeleteFailureMessage": "Dedicated gateway resource deletion failed.", + "CannotSave": "Cannot save the changes to the Dedicated gateway resource at the moment", + "DedicatedGatewayEndpoint": "Dedicated gatewayEndpoint", + "NoValue": "", + "SKUDetails": "SKU Details: ", + "CosmosD4Details": "General Purpose Cosmos Compute with 4 vCPUs, 16 GB Memory", + "CosmosD8Details": "General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory", + "CosmosD16Details": "General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory", + "CosmosD32Details": "General Purpose Cosmos Compute with 32 vCPUs, 128 GB Memory", + "Cost": "Cost", + "CostText": "Hourly cost of the dedicated gateway resource depends on the SKU selection, number of instances per region, and number of regions.", + "ConnectionString": "Connection String", + "ConnectionStringText": "To use the dedicated gateway, use the connection string shown in ", + "KeysBlade": "the keys blade", + "WarningBannerOnUpdate": "Adding or modifying dedicated gateway instances may affect your bill.", + "WarningBannerOnDelete": "After deprovisioning the dedicated gateway, you must update any applications using the old dedicated gateway connection string." + } } } \ No newline at end of file diff --git a/src/SelfServe/SqlX/SqlX.rp.ts b/src/SelfServe/SqlX/SqlX.rp.ts index bf564a7b4..b5a097fca 100644 --- a/src/SelfServe/SqlX/SqlX.rp.ts +++ b/src/SelfServe/SqlX/SqlX.rp.ts @@ -1,33 +1,98 @@ import { RefreshResult } from "../SelfServeTypes"; +import { userContext } from "../../UserContext"; +import { armRequestWithoutPolling } from "../../Utils/arm/request"; +import { configContext } from "../../ConfigContext"; +import { SqlxServiceResource, UpdateDedicatedGatewayRequestParameters } from "./SqlxTypes"; + +const apiVersion = "2020-06-01-preview"; + +export enum ResourceStatus { + Running = "Running", + Creating = "Creating", + Updating = "Updating", + Deleting = "Deleting", +} export interface DedicatedGatewayResponse { sku: string; instances: number; + status: string; + endpoint: string; } -export const getRegionSpecificMinInstances = async (): Promise => { - // TODO: write RP call to get min number of instances needed for this region - throw new Error("getRegionSpecificMinInstances not implemented"); +export const getPath = (subscriptionId: string, resourceGroup: string, name: string): string => { + return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${name}/services/sqlx`; }; -export const getRegionSpecificMaxInstances = async (): Promise => { - // TODO: write RP call to get max number of instances needed for this region - throw new Error("getRegionSpecificMaxInstances not implemented"); +export const updateDedicatedGatewayResource = async (sku: string, instances: number): Promise => { + const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name); + const body: UpdateDedicatedGatewayRequestParameters = { + properties: { + instanceSize: sku, + instanceCount: instances, + serviceType: "Sqlx", + }, + }; + const armRequestResult = await armRequestWithoutPolling({ + host: configContext.ARM_ENDPOINT, + path, + method: "PUT", + apiVersion, + body, + }); + return armRequestResult.operationStatusUrl; }; -export const updateDedicatedGatewayProvisioning = async (sku: string, instances: number): Promise => { - // TODO: write RP call to update dedicated gateway provisioning - throw new Error( - `updateDedicatedGatewayProvisioning not implemented. Parameters- sku: ${sku}, instances:${instances}` - ); +export const deleteDedicatedGatewayResource = async (): Promise => { + const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name); + const armRequestResult = await armRequestWithoutPolling({ + host: configContext.ARM_ENDPOINT, + path, + method: "DELETE", + apiVersion, + }); + return armRequestResult.operationStatusUrl; }; -export const initializeDedicatedGatewayProvisioning = async (): Promise => { - // TODO: write RP call to initialize UI for dedicated gateway provisioning - throw new Error("initializeDedicatedGatewayProvisioning not implemented"); +export const getDedicatedGatewayResource = async (): Promise => { + const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name); + const armRequestResult = await armRequestWithoutPolling({ + host: configContext.ARM_ENDPOINT, + path, + method: "GET", + apiVersion, + }); + return armRequestResult.result; +}; + +export const getCurrentProvisioningState = async (): Promise => { + try { + const response = await getDedicatedGatewayResource(); + return { + sku: response.properties.instanceSize, + instances: response.properties.instanceCount, + status: response.properties.status, + endpoint: response.properties.sqlxEndPoint, + }; + } catch (e) { + return { sku: undefined, instances: undefined, status: undefined, endpoint: undefined }; + } }; export const refreshDedicatedGatewayProvisioning = async (): Promise => { - // TODO: write RP call to check if dedicated gateway update has gone through - throw new Error("refreshDedicatedGatewayProvisioning not implemented"); + try { + const response = await getDedicatedGatewayResource(); + if (response.properties.status === ResourceStatus.Running.toString()) { + return { isUpdateInProgress: false, updateInProgressMessageTKey: undefined }; + } else if (response.properties.status === ResourceStatus.Creating.toString()) { + return { isUpdateInProgress: true, updateInProgressMessageTKey: "CreateMessage" }; + } else if (response.properties.status === ResourceStatus.Deleting.toString()) { + return { isUpdateInProgress: true, updateInProgressMessageTKey: "DeleteMessage" }; + } else { + return { isUpdateInProgress: true, updateInProgressMessageTKey: "UpdateMessage" }; + } + } catch { + //TODO differentiate between different failures + return { isUpdateInProgress: false, updateInProgressMessageTKey: undefined }; + } }; diff --git a/src/SelfServe/SqlX/SqlX.tsx b/src/SelfServe/SqlX/SqlX.tsx index 4b6a0bc2c..e8308b651 100644 --- a/src/SelfServe/SqlX/SqlX.tsx +++ b/src/SelfServe/SqlX/SqlX.tsx @@ -1,6 +1,7 @@ -import { IsDisplayable, OnChange, Values } from "../Decorators"; +import { IsDisplayable, OnChange, RefreshOptions, Values } from "../Decorators"; import { ChoiceItem, + Description, DescriptionType, InputType, NumberUiType, @@ -9,65 +10,284 @@ import { SelfServeBaseClass, SmartUiInput, } from "../SelfServeTypes"; -import { refreshDedicatedGatewayProvisioning } from "./SqlX.rp"; +import { BladeType, generateBladeLink } from "../SelfServeUtils"; +import { + deleteDedicatedGatewayResource, + getCurrentProvisioningState, + refreshDedicatedGatewayProvisioning, + updateDedicatedGatewayResource, +} from "./SqlX.rp"; + +const costPerHourValue: Description = { + textTKey: "CostText", + type: DescriptionType.Text, + link: { + href: "https://azure.microsoft.com/en-us/pricing/details/cosmos-db/", + textTKey: "DedicatedGatewayPricing", + }, +}; + +const connectionStringValue: Description = { + textTKey: "ConnectionStringText", + type: DescriptionType.Text, + link: { + href: generateBladeLink(BladeType.SqlKeys), + textTKey: "KeysBlade", + }, +}; + +const CosmosD4s = "Cosmos.D4s"; +const CosmosD8s = "Cosmos.D8s"; +const CosmosD16s = "Cosmos.D16s"; +const CosmosD32s = "Cosmos.D32s"; + +const getSKUDetails = (sku: string): string => { + if (sku === CosmosD4s) { + return "CosmosD4Details"; + } else if (sku === CosmosD8s) { + return "CosmosD8Details"; + } else if (sku === CosmosD16s) { + return "CosmosD16Details"; + } else if (sku === CosmosD32s) { + return "CosmosD32Details"; + } + return "Not Supported Yet"; +}; + +const onSKUChange = (newValue: InputType, currentValues: Map): Map => { + currentValues.set("sku", { value: newValue }); + currentValues.set("skuDetails", { + value: { textTKey: getSKUDetails(`${newValue.toString()}`), type: DescriptionType.Text } as Description, + }); + currentValues.set("costPerHour", { value: costPerHourValue }); + return currentValues; +}; + +const onNumberOfInstancesChange = ( + newValue: InputType, + currentValues: Map +): Map => { + currentValues.set("instances", { value: newValue }); + currentValues.set("warningBanner", { + value: { textTKey: "WarningBannerOnUpdate" } as Description, + hidden: false, + }); + + return currentValues; +}; const onEnableDedicatedGatewayChange = ( newValue: InputType, - currentState: Map + currentValues: Map, + baselineValues: ReadonlyMap ): Map => { - const sku = currentState.get("sku"); - const instances = currentState.get("instances"); - const isSkuHidden = newValue === undefined || !(newValue as boolean); - currentState.set("enableDedicatedGateway", { value: newValue }); - currentState.set("sku", { value: sku.value, hidden: isSkuHidden }); - currentState.set("instances", { value: instances.value, hidden: isSkuHidden }); - return currentState; + currentValues.set("enableDedicatedGateway", { value: newValue }); + const dedicatedGatewayOriginallyEnabled = baselineValues.get("enableDedicatedGateway")?.value as boolean; + if (dedicatedGatewayOriginallyEnabled === newValue) { + currentValues.set("sku", baselineValues.get("sku")); + currentValues.set("instances", baselineValues.get("instances")); + currentValues.set("skuDetails", baselineValues.get("skuDetails")); + currentValues.set("costPerHour", baselineValues.get("costPerHour")); + currentValues.set("warningBanner", baselineValues.get("warningBanner")); + currentValues.set("connectionString", baselineValues.get("connectionString")); + return currentValues; + } + + currentValues.set("warningBanner", undefined); + if (newValue === true) { + currentValues.set("warningBanner", { + value: { + textTKey: "WarningBannerOnUpdate", + link: { + href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", + textTKey: "DedicatedGatewayPricing", + }, + } as Description, + hidden: false, + }); + } else { + currentValues.set("warningBanner", { + value: { + textTKey: "WarningBannerOnDelete", + link: { + href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", + textTKey: "DeprovisioningDetailsText", + }, + } as Description, + hidden: false, + }); + } + const sku = currentValues.get("sku"); + const instances = currentValues.get("instances"); + const hideAttributes = newValue === undefined || !(newValue as boolean); + currentValues.set("sku", { + value: sku.value, + hidden: hideAttributes, + disabled: dedicatedGatewayOriginallyEnabled, + }); + currentValues.set("instances", { + value: instances.value, + hidden: hideAttributes, + disabled: dedicatedGatewayOriginallyEnabled, + }); + + currentValues.set("skuDetails", { + value: { textTKey: getSKUDetails(`${currentValues.get("sku").value}`), type: DescriptionType.Text } as Description, + hidden: hideAttributes, + disabled: dedicatedGatewayOriginallyEnabled, + }); + + currentValues.set("costPerHour", { value: costPerHourValue, hidden: hideAttributes }); + currentValues.set("connectionString", { + value: connectionStringValue, + hidden: !newValue || !dedicatedGatewayOriginallyEnabled, + }); + + return currentValues; }; +const skuDropDownItems: ChoiceItem[] = [ + { label: "CosmosD4s", key: CosmosD4s }, + { label: "CosmosD8s", key: CosmosD8s }, + { label: "CosmosD16s", key: CosmosD16s }, + { label: "CosmosD32s", key: CosmosD32s }, +]; + const getSkus = async (): Promise => { - // TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. - throw new Error("getSkus not implemented."); + return skuDropDownItems; }; const getInstancesMin = async (): Promise => { - // TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. - throw new Error("getInstancesMin not implemented."); + return 1; }; const getInstancesMax = async (): Promise => { - // TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. - throw new Error("getInstancesMax not implemented."); -}; - -const validate = (currentValues: Map): void => { - // TODO: add cusom validation logic to be called before Saving the data. - throw new Error(`validate not implemented. No. of properties to validate: ${currentValues.size}`); + return 5; }; @IsDisplayable() +@RefreshOptions({ retryIntervalInMs: 20000 }) export default class SqlX extends SelfServeBaseClass { public onRefresh = async (): Promise => { - return refreshDedicatedGatewayProvisioning(); + return await refreshDedicatedGatewayProvisioning(); }; - public onSave = async (currentValues: Map): Promise => { - validate(currentValues); - // TODO: add pre processing logic before calling the updateDedicatedGatewayProvisioning() RP call. - throw new Error(`onSave not implemented. No. of properties to save: ${currentValues.size}`); + public onSave = async ( + currentValues: Map, + baselineValues: Map + ): Promise => { + const dedicatedGatewayCurrentlyEnabled = currentValues.get("enableDedicatedGateway")?.value as boolean; + const dedicatedGatewayOriginallyEnabled = baselineValues.get("enableDedicatedGateway")?.value as boolean; + + currentValues.set("warningBanner", undefined); + + //TODO : Add try catch for each RP call and return relevant notifications + if (dedicatedGatewayOriginallyEnabled) { + if (!dedicatedGatewayCurrentlyEnabled) { + const operationStatusUrl = await deleteDedicatedGatewayResource(); + return { + operationStatusUrl: operationStatusUrl, + portalNotification: { + initialize: { + titleTKey: "DeleteInitializeTitle", + messageTKey: "DeleteInitializeMessage", + }, + success: { + titleTKey: "DeleteSuccessTitle", + messageTKey: "DeleteSuccesseMessage", + }, + failure: { + titleTKey: "DeleteFailureTitle", + messageTKey: "DeleteFailureMessage", + }, + }, + }; + } else { + // Check for scaling up/down/in/out + return { + operationStatusUrl: undefined, + portalNotification: { + initialize: { + titleTKey: "UpdateInitializeTitle", + messageTKey: "UpdateInitializeMessage", + }, + success: { + titleTKey: "UpdateSuccessTitle", + messageTKey: "UpdateSuccesseMessage", + }, + failure: { + titleTKey: "UpdateFailureTitle", + messageTKey: "UpdateFailureMessage", + }, + }, + }; + } + } else { + const sku = currentValues.get("sku")?.value as string; + const instances = currentValues.get("instances").value as number; + const operationStatusUrl = await updateDedicatedGatewayResource(sku, instances); + return { + operationStatusUrl: operationStatusUrl, + portalNotification: { + initialize: { + titleTKey: "CreateInitializeTitle", + messageTKey: "CreateInitializeTitle", + }, + success: { + titleTKey: "CreateSuccessTitle", + messageTKey: "CreateSuccesseMessage", + }, + failure: { + titleTKey: "CreateFailureTitle", + messageTKey: "CreateFailureMessage", + }, + }, + }; + } }; public initialize = async (): Promise> => { - // TODO: get initialization data from initializeDedicatedGatewayProvisioning() RP call. - throw new Error("onSave not implemented"); + // Based on the RP call enableDedicatedGateway will be true if it has not yet been enabled and false if it has. + const defaults = new Map(); + defaults.set("enableDedicatedGateway", { value: false }); + defaults.set("sku", { value: CosmosD4s, hidden: true }); + defaults.set("instances", { value: await getInstancesMin(), hidden: true }); + defaults.set("skuDetails", undefined); + defaults.set("costPerHour", undefined); + defaults.set("connectionString", undefined); + + const response = await getCurrentProvisioningState(); + if (response.status && response.status !== "Deleting") { + defaults.set("enableDedicatedGateway", { value: true }); + defaults.set("sku", { value: response.sku, disabled: true }); + defaults.set("instances", { value: response.instances, disabled: true }); + defaults.set("costPerHour", { value: costPerHourValue }); + defaults.set("skuDetails", { + value: { textTKey: getSKUDetails(`${defaults.get("sku").value}`), type: DescriptionType.Text } as Description, + hidden: false, + }); + defaults.set("connectionString", { + value: connectionStringValue, + hidden: false, + }); + } + + defaults.set("warningBanner", undefined); + return defaults; }; + @Values({ + isDynamicDescription: true, + }) + warningBanner: string; + @Values({ description: { - textTKey: "Provisioning dedicated gateways for SqlX accounts.", + textTKey: "DedicatedGatewayDescription", type: DescriptionType.Text, link: { href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", - textTKey: "Learn more about dedicated gateway.", + textTKey: "LearnAboutDedicatedGateway", }, }, }) @@ -75,25 +295,45 @@ export default class SqlX extends SelfServeBaseClass { @OnChange(onEnableDedicatedGatewayChange) @Values({ - labelTKey: "Dedicated Gateway", - trueLabelTKey: "Enable", - falseLabelTKey: "Disable", + labelTKey: "DedicatedGateway", + trueLabelTKey: "Provisioned", + falseLabelTKey: "Deprovisioned", }) enableDedicatedGateway: boolean; + @OnChange(onSKUChange) @Values({ labelTKey: "SKUs", choices: getSkus, - placeholderTKey: "Select SKUs", + placeholderTKey: "SKUsPlaceHolder", }) sku: ChoiceItem; @Values({ - labelTKey: "Number of instances", + labelTKey: "SKUDetails", + isDynamicDescription: true, + }) + skuDetails: string; + + @OnChange(onNumberOfInstancesChange) + @Values({ + labelTKey: "NumberOfInstances", min: getInstancesMin, max: getInstancesMax, step: 1, uiType: NumberUiType.Spinner, }) instances: number; + + @Values({ + labelTKey: "Cost", + isDynamicDescription: true, + }) + costPerHour: string; + + @Values({ + labelTKey: "ConnectionString", + isDynamicDescription: true, + }) + connectionString: string; } diff --git a/src/SelfServe/SqlX/SqlxTypes.ts b/src/SelfServe/SqlX/SqlxTypes.ts new file mode 100644 index 000000000..70557f4f4 --- /dev/null +++ b/src/SelfServe/SqlX/SqlxTypes.ts @@ -0,0 +1,31 @@ +export type SqlxServiceResource = { + id: string; + name: string; + type: string; + properties: SqlxServiceProps; + locations: SqlxServiceLocations; +}; +export type SqlxServiceProps = { + serviceType: string; + creationTime: string; + status: string; + instanceSize: string; + instanceCount: number; + sqlxEndPoint: string; +}; + +export type SqlxServiceLocations = { + location: string; + status: string; + sqlxEndpoint: string; +}; + +export type UpdateDedicatedGatewayRequestParameters = { + properties: UpdateDedicatedGatewayRequestProperties; +}; + +export type UpdateDedicatedGatewayRequestProperties = { + instanceSize: string; + instanceCount: number; + serviceType: string; +};