diff --git a/extensions/arc/images/discard.svg b/extensions/arc/images/discard.svg new file mode 100644 index 0000000000..356c911744 --- /dev/null +++ b/extensions/arc/images/discard.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/arc/images/information.svg b/extensions/arc/images/information.svg new file mode 100644 index 0000000000..150ebdfdce --- /dev/null +++ b/extensions/arc/images/information.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/arc/images/save.svg b/extensions/arc/images/save.svg new file mode 100644 index 0000000000..048cd89aa3 --- /dev/null +++ b/extensions/arc/images/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/arc/src/common/utils.ts b/extensions/arc/src/common/utils.ts index f86c864342..23620c5695 100644 --- a/extensions/arc/src/common/utils.ts +++ b/extensions/arc/src/common/utils.ts @@ -218,6 +218,63 @@ export function createCredentialId(controllerId: string, resourceType: string, i } /** + * Calculates the gibibyte (GiB) conversion of a quantity that could currently be represented by a range + * of SI suffixes (E, P, T, G, M, K, m) or their power-of-two equivalents (Ei, Pi, Ti, Gi, Mi, Ki) + * @param value The string of a quantity to be converted + * @returns String of GiB conversion + */ +export function convertToGibibyteString(value: string): string { + if (!value) { + throw new Error(`Value provided is not a valid Kubernetes resource quantity`); + } + + let base10ToBase2Multiplier; + let floatValue = parseFloat(value); + let splitValue = value.split(String(floatValue)); + let unit = splitValue[1]; + + if (unit === 'K') { + base10ToBase2Multiplier = 1000 / 1024; + floatValue = (floatValue * base10ToBase2Multiplier) / Math.pow(1024, 2); + } else if (unit === 'M') { + base10ToBase2Multiplier = Math.pow(1000, 2) / Math.pow(1024, 2); + floatValue = (floatValue * base10ToBase2Multiplier) / 1024; + } else if (unit === 'G') { + base10ToBase2Multiplier = Math.pow(1000, 3) / Math.pow(1024, 3); + floatValue = floatValue * base10ToBase2Multiplier; + } else if (unit === 'T') { + base10ToBase2Multiplier = Math.pow(1000, 4) / Math.pow(1024, 4); + floatValue = (floatValue * base10ToBase2Multiplier) * 1024; + } else if (unit === 'P') { + base10ToBase2Multiplier = Math.pow(1000, 5) / Math.pow(1024, 5); + floatValue = (floatValue * base10ToBase2Multiplier) * Math.pow(1024, 2); + } else if (unit === 'E') { + base10ToBase2Multiplier = Math.pow(1000, 6) / Math.pow(1024, 6); + floatValue = (floatValue * base10ToBase2Multiplier) * Math.pow(1024, 3); + } else if (unit === 'm') { + floatValue = (floatValue / 1000) / Math.pow(1024, 3); + } else if (unit === '') { + floatValue = floatValue / Math.pow(1024, 3); + } else if (unit === 'Ki') { + floatValue = floatValue / Math.pow(1024, 2); + } else if (unit === 'Mi') { + floatValue = floatValue / 1024; + } else if (unit === 'Gi') { + floatValue = floatValue; + } else if (unit === 'Ti') { + floatValue = floatValue * 1024; + } else if (unit === 'Pi') { + floatValue = floatValue * Math.pow(1024, 2); + } else if (unit === 'Ei') { + floatValue = floatValue * Math.pow(1024, 3); + } else { + throw new Error(`${value} is not a valid Kubernetes resource quantity`); + } + + return String(floatValue); +} + +/* * Throws an Error with given {@link message} unless {@link condition} is true. * This also tells the typescript compiler that the condition is 'truthy' in the remainder of the scope * where this function was called. diff --git a/extensions/arc/src/constants.ts b/extensions/arc/src/constants.ts index 7522f9373a..c6d3e8ce3f 100644 --- a/extensions/arc/src/constants.ts +++ b/extensions/arc/src/constants.ts @@ -40,7 +40,10 @@ export class IconPathHelper { public static controller: IconPath; public static health: IconPath; public static success: IconPath; + public static save: IconPath; + public static discard: IconPath; public static fail: IconPath; + public static information: IconPath; public static setExtensionContext(context: vscode.ExtensionContext) { IconPathHelper.context = context; @@ -116,10 +119,22 @@ export class IconPathHelper { light: context.asAbsolutePath('images/success.svg'), dark: context.asAbsolutePath('images/success.svg'), }; + IconPathHelper.save = { + light: context.asAbsolutePath('images/save.svg'), + dark: context.asAbsolutePath('images/save.svg'), + }; + IconPathHelper.discard = { + light: context.asAbsolutePath('images/discard.svg'), + dark: context.asAbsolutePath('images/discard.svg'), + }; IconPathHelper.fail = { light: context.asAbsolutePath('images/fail.svg'), dark: context.asAbsolutePath('images/fail.svg'), }; + IconPathHelper.information = { + light: context.asAbsolutePath('images/information.svg'), + dark: context.asAbsolutePath('images/information.svg'), + }; } } diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 699309377e..9628ffc5a0 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -32,6 +32,8 @@ export const resourceHealth = localize('arc.resourceHealth', "Resource health"); export const newInstance = localize('arc.createNew', "New Instance"); export const deleteText = localize('arc.delete', "Delete"); +export const saveText = localize('arc.save', "Save"); +export const discardText = localize('arc.discard', "Discard"); export const resetPassword = localize('arc.resetPassword', "Reset Password"); export const openInAzurePortal = localize('arc.openInAzurePortal', "Open in Azure Portal"); export const resourceGroup = localize('arc.resourceGroup', "Resource Group"); @@ -59,6 +61,10 @@ export const yes = localize('arc.yes', "Yes"); export const no = localize('arc.no', "No"); export const feedback = localize('arc.feedback', "Feedback"); export const selectConnectionString = localize('arc.selectConnectionString', "Select from available client connection strings below."); +export const addingWokerNodes = localize('arc.addingWokerNodes', "adding worker nodes"); +export const workerNodesDescription = localize('arc.workerNodesDescription', "Expand your server group and scale your database by adding worker nodes."); +export const configurationInformation = localize('arc.configurationInformation', "You can configure the number of CPU cores and storage size that will apply to both worker nodes and coordinator node. Each worker node will have the same configuration. Adjust the number of CPU cores and memory settings for your server group."); +export const workerNodesInformation = localize('arc.workerNodeInformation', "In preview it is not possible to reduce the number of worker nodes. Please refer to documentation linked above for more information."); export const vCores = localize('arc.vCores', "vCores"); export const ram = localize('arc.ram', "RAM"); export const refresh = localize('arc.refresh', "Refresh"); @@ -114,9 +120,27 @@ export const databaseName = localize('arc.databaseName', "Database name"); export const enterNewPassword = localize('arc.enterNewPassword', "Enter a new password"); export const confirmNewPassword = localize('arc.confirmNewPassword', "Confirm the new password"); export const learnAboutPostgresClients = localize('arc.learnAboutPostgresClients', "Learn more about Azure PostgreSQL Hyperscale client interfaces"); +export const scalingCompute = localize('arc.scalingCompute', "scaling compute vCores and memory."); +export const computeAndStorageDescriptionPartOne = localize('arc.computeAndStorageDescriptionPartOne', "You can scale your Azure Arc enabled"); +export const computeAndStorageDescriptionPartTwo = localize('arc.computeAndStorageDescriptionPartTwo', "PostgreSQL Hyperscale server group by"); +export const computeAndStorageDescriptionPartThree = localize('arc.computeAndStorageDescriptionPartThree', "without downtime and by"); +export const computeAndStorageDescriptionPartFour = localize('arc.computeAndStorageDescriptionPartFour', "Before doing so, you need to ensure"); +export const computeAndStorageDescriptionPartFive = localize('arc.computeAndStorageDescriptionPartFive', "there are sufficient resources available"); +export const computeAndStorageDescriptionPartSix = localize('arc.computeAndStorageDescriptionPartSix', "in your Kubernetes cluster to honor this configuration."); export const node = localize('arc.node', "node"); export const nodes = localize('arc.nodes', "nodes"); +export const workerNodes = localize('arc.workerNodes', "Worker Nodes"); export const storagePerNode = localize('arc.storagePerNode', "storage per node"); +export const workerNodeCount = localize('arc.workerNodeCount', "Worker node count:"); +export const configurationPerNode = localize('arc.configurationPerNode', "Configuration (per node)"); +export const coresLimit = localize('arc.coresLimit', "CPU limit:"); +export const coresRequest = localize('arc.coresRequest', "CPU request:"); +export const memoryLimit = localize('arc.memoryLimit', "Memory limit (in GB):"); +export const memoryRequest = localize('arc.memoryRequest', "Memory request (in GB):"); +export const workerValidationErrorMessage = localize('arc.workerValidationErrorMessage', "The number of workers cannot be decreased."); +export const coresValidationErrorMessage = localize('arc.coresValidationErrorMessage', "Valid CPU resource quantities are strictly positive."); +export const memoryRequestValidationErrorMessage = localize('arc.memoryRequestValidationErrorMessage', "Memory request must be at least 0.25Gib"); +export const memoryLimitValidationErrorMessage = localize('arc.memoryLimitValidationErrorMessage', "Memory limit must be at least 0.25Gib"); export const arcResources = localize('arc.arcResources', "Azure Arc Resources"); export const enterANonEmptyPassword = localize('arc.enterANonEmptyPassword', "Enter a non empty password or press escape to exit."); export const thePasswordsDoNotMatch = localize('arc.thePasswordsDoNotMatch', "The passwords do not match. Confirm the password or press escape to exit."); @@ -130,7 +154,9 @@ export const podsReady = localize('arc.podsReady', "pods ready"); export function databaseCreated(name: string): string { return localize('arc.databaseCreated', "Database {0} created", name); } export function deletingInstance(name: string): string { return localize('arc.deletingInstance', "Deleting instance '{0}'...", name); } +export function updatingInstance(name: string): string { return localize('arc.updatingInstance', "Updating instance '{0}'...", name); } export function instanceDeleted(name: string): string { return localize('arc.instanceDeleted', "Instance '{0}' deleted", name); } +export function instanceUpdated(name: string): string { return localize('arc.instanceUpdated', "Instance '{0}' updated", name); } export function copiedToClipboard(name: string): string { return localize('arc.copiedToClipboard', "{0} copied to clipboard", name); } export function clickTheTroubleshootButton(resourceType: string): string { return localize('arc.clickTheTroubleshootButton', "Click the troubleshoot button to open the Azure Arc {0} troubleshooting notebook.", resourceType); } export function numVCores(vCores: string | undefined): string { @@ -152,6 +178,8 @@ export const couldNotFindControllerRegistration = localize('arc.couldNotFindCont export function refreshFailed(error: any): string { return localize('arc.refreshFailed', "Refresh failed. {0}", getErrorMessage(error)); } export function openDashboardFailed(error: any): string { return localize('arc.openDashboardFailed', "Error opening dashboard. {0}", getErrorMessage(error)); } export function instanceDeletionFailed(name: string, error: any): string { return localize('arc.instanceDeletionFailed', "Failed to delete instance {0}. {1}", name, getErrorMessage(error)); } +export function instanceUpdateFailed(name: string, error: any): string { return localize('arc.instanceUpdateFailed', "Failed to update instance {0}. {1}", name, getErrorMessage(error)); } +export function pageDiscardFailed(error: any): string { return localize('arc.pageDiscardFailed', "Failed to discard user input. {0}", getErrorMessage(error)); } export function databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database {0}. {1}", name, getErrorMessage(error)); } export function connectToControllerFailed(url: string, error: any): string { return localize('arc.connectToControllerFailed', "Could not connect to controller {0}. {1}", url, getErrorMessage(error)); } export function connectToSqlFailed(serverName: string, error: any): string { return localize('arc.connectToSqlFailed', "Could not connect to SQL managed instance - Azure Arc Instance {0}. {1}", serverName, getErrorMessage(error)); } diff --git a/extensions/arc/src/test/common/utils.test.ts b/extensions/arc/src/test/common/utils.test.ts index 207f14c96d..0ce066fc3d 100644 --- a/extensions/arc/src/test/common/utils.test.ts +++ b/extensions/arc/src/test/common/utils.test.ts @@ -7,7 +7,7 @@ import { ResourceType } from 'arc'; import 'mocha'; import * as should from 'should'; import * as vscode from 'vscode'; -import { getAzurecoreApi, getConnectionModeDisplayText, getDatabaseStateDisplayText, getErrorMessage, getResourceTypeIcon, parseEndpoint, parseIpAndPort, promptAndConfirmPassword, promptForInstanceDeletion, resourceTypeToDisplayName } from '../../common/utils'; +import { getAzurecoreApi, getConnectionModeDisplayText, getDatabaseStateDisplayText, getErrorMessage, getResourceTypeIcon, parseEndpoint, parseIpAndPort, promptAndConfirmPassword, promptForInstanceDeletion, resourceTypeToDisplayName, convertToGibibyteString } from '../../common/utils'; import { ConnectionMode as ConnectionMode, IconPathHelper } from '../../constants'; import * as loc from '../../localizedConstants'; import { MockInputBox } from '../stubs'; @@ -254,3 +254,116 @@ describe('parseIpAndPort', function (): void { should(() => parseIpAndPort(ip)).throwError(); }); }); + +describe('convertToGibibyteString Method Tests', function () { + const tolerance = 0.001; + it('Value is in KB', function (): void { + const value = '44000K'; + const conversion = 0.04097819; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in MB', function (): void { + const value = '1100M'; + const conversion = 1.02445483; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in GB', function (): void { + const value = '1G'; + const conversion = 0.931322575; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in TB', function (): void { + const value = '1T'; + const conversion = 931.32257; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in PB', function (): void { + const value = '0.1P'; + const conversion = 93132.25746; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in EB', function (): void { + const value = '1E'; + const conversion = 931322574.6154; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in mB', function (): void { + const value = '1073741824000m'; + const conversion = 1; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in B', function (): void { + const value = '1073741824'; + const conversion = 1; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in KiB', function (): void { + const value = '1048576Ki'; + const conversion = 1; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in MiB', function (): void { + const value = '256Mi'; + const conversion = 0.25; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in GiB', function (): void { + const value = '1000Gi'; + const conversion = 1000; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in TiB', function (): void { + const value = '1Ti'; + const conversion = 1024; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in PiB', function (): void { + const value = '1Pi'; + const conversion = 1048576; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is in EiB', function (): void { + const value = '1Ei'; + const conversion = 1073741824; + const check = Math.abs(conversion - parseFloat(convertToGibibyteString(value))); + should(check).lessThanOrEqual(tolerance); + }); + + it('Value is empty', function (): void { + const value = ''; + const error = new Error(`Value provided is not a valid Kubernetes resource quantity`); + should(() => convertToGibibyteString(value)).throwError(error); + }); + + it('Value is not a valid Kubernetes resource quantity', function (): void { + const value = '1J'; + const error = new Error(`${value} is not a valid Kubernetes resource quantity`); + should(() => convertToGibibyteString(value)).throwError(error); + }); +}); diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts new file mode 100644 index 0000000000..f466a428f6 --- /dev/null +++ b/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts @@ -0,0 +1,499 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; +import * as azdataExt from 'azdata-ext'; +import * as loc from '../../../localizedConstants'; +import { IconPathHelper, cssStyles } from '../../../constants'; +import { DashboardPage } from '../../components/dashboardPage'; +import { PostgresModel } from '../../../models/postgresModel'; +import { convertToGibibyteString } from '../../../common/utils'; + +export class PostgresComputeAndStoragePage extends DashboardPage { + private workerContainer?: azdata.DivContainer; + + private workerBox?: azdata.InputBoxComponent; + private coresLimitBox?: azdata.InputBoxComponent; + private coresRequestBox?: azdata.InputBoxComponent; + private memoryLimitBox?: azdata.InputBoxComponent; + private memoryRequestBox?: azdata.InputBoxComponent; + + private discardButton?: azdata.ButtonComponent; + private saveButton?: azdata.ButtonComponent; + + private saveArgs: { + workers?: number, + coresLimit?: string, + coresRequest?: string, + memoryLimit?: string, + memoryRequest?: string + } = {}; + + private readonly _azdataApi: azdataExt.IExtension; + + constructor(protected modelView: azdata.ModelView, private _postgresModel: PostgresModel) { + super(modelView); + this._azdataApi = vscode.extensions.getExtension(azdataExt.extension.name)?.exports; + + this.initializeConfigurationBoxes(); + + this.disposables.push(this._postgresModel.onConfigUpdated( + () => this.eventuallyRunOnInitialized(() => this.handleServiceUpdated()))); + } + + protected get title(): string { + return loc.computeAndStorage; + } + + protected get id(): string { + return 'postgres-compute-and-storage'; + } + + protected get icon(): { dark: string; light: string; } { + return IconPathHelper.computeStorage; + } + + protected get container(): azdata.Component { + const root = this.modelView.modelBuilder.divContainer().component(); + const content = this.modelView.modelBuilder.divContainer().component(); + root.addItem(content, { CSSStyles: { 'margin': '20px' } }); + + content.addItem(this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorage, + CSSStyles: { ...cssStyles.title } + }).component()); + + const infoComputeStorage_p1 = this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorageDescriptionPartOne, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px', 'max-width': 'auto' } + }).component(); + const infoComputeStorage_p2 = this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorageDescriptionPartTwo, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const workerNodeslink = this.modelView.modelBuilder.hyperlink().withProperties({ + label: loc.addingWokerNodes, + url: 'https://docs.microsoft.com/azure/azure-arc/data/scale-up-down-postgresql-hyperscale-server-group-using-cli', + CSSStyles: { 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const infoComputeStorage_p3 = this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorageDescriptionPartThree, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const memoryVCoreslink = this.modelView.modelBuilder.hyperlink().withProperties({ + label: loc.scalingCompute, + url: 'https://docs.microsoft.com/azure/azure-arc/data/scale-up-down-postgresql-hyperscale-server-group-using-cli', + CSSStyles: { 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const infoComputeStorage_p4 = this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorageDescriptionPartFour, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const infoComputeStorage_p5 = this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorageDescriptionPartFive, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const infoComputeStorage_p6 = this.modelView.modelBuilder.text().withProperties({ + value: loc.computeAndStorageDescriptionPartSix, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const computeInfoAndLinks = this.modelView.modelBuilder.flexContainer().withLayout({ flexWrap: 'wrap' }).component(); + computeInfoAndLinks.addItem(infoComputeStorage_p1, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(infoComputeStorage_p2, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(workerNodeslink, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(infoComputeStorage_p3, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(memoryVCoreslink, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(infoComputeStorage_p4, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(infoComputeStorage_p5, { CSSStyles: { 'margin-right': '5px' } }); + computeInfoAndLinks.addItem(infoComputeStorage_p6, { CSSStyles: { 'margin-right': '5px' } }); + content.addItem(computeInfoAndLinks, { CSSStyles: { 'min-height': '30px' } }); + + content.addItem(this.modelView.modelBuilder.text().withProperties({ + value: loc.workerNodes, + CSSStyles: { ...cssStyles.title, 'margin-top': '25px' } + }).component()); + + this.workerContainer = this.modelView.modelBuilder.divContainer().component(); + this.workerContainer.addItems(this.createUserInputSection(), { CSSStyles: { 'min-height': '30px' } }); + content.addItem(this.workerContainer, { CSSStyles: { 'min-height': '30px' } }); + + this.initialized = true; + + return root; + } + + protected get toolbarContainer(): azdata.ToolbarContainer { + // Save Edits + this.saveButton = this.modelView.modelBuilder.button().withProperties({ + label: loc.saveText, + iconPath: IconPathHelper.save, + enabled: false + }).component(); + + this.disposables.push( + this.saveButton.onDidClick(async () => { + this.saveButton!.enabled = false; + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.updatingInstance(this._postgresModel.info.name), + cancellable: false + }, + (_progress, _token) => { + return this._azdataApi.azdata.arc.postgres.server.edit( + this._postgresModel.info.name, this.saveArgs); + } + ); + + this._postgresModel.refresh(); + + vscode.window.showInformationMessage(loc.instanceUpdated(this._postgresModel.info.name)); + + } catch (error) { + vscode.window.showErrorMessage(loc.instanceUpdateFailed(this._postgresModel.info.name, error)); + } finally { + this.discardButton!.enabled = false; + } + })); + + // Discard + this.discardButton = this.modelView.modelBuilder.button().withProperties({ + label: loc.discardText, + iconPath: IconPathHelper.discard, + enabled: false + }).component(); + + this.disposables.push( + this.discardButton.onDidClick(async () => { + this.discardButton!.enabled = false; + try { + this.editWorkerNodeCount(); + this.editCores(); + this.editMemory(); + } catch (error) { + vscode.window.showErrorMessage(loc.pageDiscardFailed(error)); + } finally { + this.saveButton!.enabled = false; + } + })); + + return this.modelView.modelBuilder.toolbarContainer().withToolbarItems([ + { component: this.saveButton }, + { component: this.discardButton } + ]).component(); + } + + private initializeConfigurationBoxes() { + this.workerBox = this.modelView.modelBuilder.inputBox().withProperties({ + readOnly: false, + validationErrorMessage: loc.workerValidationErrorMessage, + inputType: 'number', + placeHolder: loc.loading + }).component(); + + this.disposables.push( + this.workerBox!.onTextChanged(() => { + if (!(this.handleOnTextChanged(this.workerBox!))) { + this.saveArgs.workers = undefined; + } else { + this.saveArgs.workers = parseInt(this.workerBox!.value!); + } + }) + ); + + this.coresLimitBox = this.modelView.modelBuilder.inputBox().withProperties({ + readOnly: false, + min: 1, + validationErrorMessage: loc.coresValidationErrorMessage, + inputType: 'number', + placeHolder: loc.loading + }).component(); + + this.disposables.push( + this.coresLimitBox!.onTextChanged(() => { + if (!(this.handleOnTextChanged(this.coresLimitBox!))) { + this.saveArgs.coresRequest = undefined; + } else { + this.saveArgs.coresRequest = this.coresLimitBox!.value; + } + }) + ); + + this.coresRequestBox = this.modelView.modelBuilder.inputBox().withProperties({ + readOnly: false, + min: 1, + validationErrorMessage: loc.coresValidationErrorMessage, + inputType: 'number', + placeHolder: loc.loading + }).component(); + + this.disposables.push( + this.coresRequestBox!.onTextChanged(() => { + if (!(this.handleOnTextChanged(this.coresRequestBox!))) { + this.saveArgs.coresLimit = undefined; + } else { + this.saveArgs.coresLimit = this.coresRequestBox!.value; + } + }) + ); + + this.memoryLimitBox = this.modelView.modelBuilder.inputBox().withProperties({ + readOnly: false, + min: 0.25, + validationErrorMessage: loc.memoryLimitValidationErrorMessage, + inputType: 'number', + placeHolder: loc.loading + }).component(); + + this.disposables.push( + this.memoryLimitBox!.onTextChanged(() => { + if (!(this.handleOnTextChanged(this.memoryLimitBox!))) { + this.saveArgs.memoryRequest = undefined; + } else { + this.saveArgs.memoryRequest = this.memoryLimitBox!.value + 'Gi'; + } + }) + ); + + this.memoryRequestBox = this.modelView.modelBuilder.inputBox().withProperties({ + readOnly: false, + min: 0.25, + validationErrorMessage: loc.memoryRequestValidationErrorMessage, + inputType: 'number', + placeHolder: loc.loading + }).component(); + + this.disposables.push( + this.memoryRequestBox!.onTextChanged(() => { + if (!(this.handleOnTextChanged(this.memoryRequestBox!))) { + this.saveArgs.memoryLimit = undefined; + } else { + this.saveArgs.memoryLimit = this.memoryRequestBox!.value + 'Gi'; + } + }) + ); + + } + + private createUserInputSection(): azdata.Component[] { + if (this._postgresModel.configLastUpdated) { + this.editWorkerNodeCount(); + this.editCores(); + this.editMemory(); + } + + return [ + this.createWorkerNodesSectionContainer(), + this.createCoresMemorySection(), + this.createConfigurationSectionContainer(loc.coresRequest, this.coresRequestBox!, '40px'), + this.createConfigurationSectionContainer(loc.coresLimit, this.coresLimitBox!, '40px'), + this.createConfigurationSectionContainer(loc.memoryRequest, this.memoryRequestBox!, '40px'), + this.createConfigurationSectionContainer(loc.memoryLimit, this.memoryLimitBox!, '20px') + + ]; + } + + private createWorkerNodesSectionContainer(): azdata.FlexContainer { + const inputFlex = { flex: '0 1 150px' }; + const keyFlex = { flex: `0 1 250px` }; + + const flexContainer = this.modelView.modelBuilder.flexContainer().withLayout({ + flexWrap: 'wrap', + alignItems: 'center' + }).component(); + + const keyComponent = this.modelView.modelBuilder.text().withProperties({ + value: loc.workerNodeCount, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const keyContainer = this.modelView.modelBuilder.flexContainer().withLayout({ alignItems: 'center' }).component(); + keyContainer.addItem(keyComponent, { CSSStyles: { 'margin-right': '0px', 'margin-bottom': '15px' } }); + + const information = this.modelView.modelBuilder.button().withProperties({ + iconPath: IconPathHelper.information, + title: loc.workerNodesInformation, + width: '12px', + height: '12px', + enabled: false + }).component(); + + keyContainer.addItem(information, { CSSStyles: { 'margin-left': '5px', 'margin-bottom': '15px' } }); + flexContainer.addItem(keyContainer, keyFlex); + + const inputContainer = this.modelView.modelBuilder.flexContainer().withLayout({ alignItems: 'center' }).component(); + inputContainer.addItem(this.workerBox!, { CSSStyles: { 'margin-bottom': '15px', 'min-width': '50px', 'max-width': '225px' } }); + + flexContainer.addItem(inputContainer, inputFlex); + + return flexContainer; + } + + private createConfigurationSectionContainer(key: string, input: azdata.Component, nestingLineHeight: string): azdata.FlexContainer { + const inputFlex = { flex: '0 1 150px' }; + const keyFlex = { flex: `0 1 200px` }; + const bottomLineFlex = { flex: `0 1 45px` }; + + const flexContainer = this.modelView.modelBuilder.flexContainer().withLayout({ + flexWrap: 'nowrap', + alignItems: 'center' + }).component(); + + const leftLine = this.modelView.modelBuilder.divContainer().withProperties({ + CSSStyles: { 'max-height': nestingLineHeight, 'min-height': nestingLineHeight, 'max-width': '1px', 'border-left-style': 'solid', 'border-left-color': '#ccc' } + }).component(); + + flexContainer.addItem(leftLine, { CSSStyles: { 'align-self': 'flex-start' } }); + + const bottomLine = this.modelView.modelBuilder.divContainer().withProperties({ + CSSStyles: { 'margin-right': '5px', 'min-width': '5px', 'border-bottom-style': 'solid', 'border-bottom-color': '#ccc' } + }).component(); + + flexContainer.addItem(bottomLine, bottomLineFlex); + + const keyComponent = this.modelView.modelBuilder.text().withProperties({ + value: key, + CSSStyles: { ...cssStyles.text, 'font-weight': 'bold', 'min-width': '100px', 'margin-bottom': '10px', 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + flexContainer.addItem(keyComponent, keyFlex); + + const inputContainer = this.modelView.modelBuilder.flexContainer().withLayout({ alignItems: 'center' }).component(); + inputContainer.addItem(input, { CSSStyles: { 'margin-bottom': '10px', 'min-width': '50px', 'max-width': '225px' } }); + + flexContainer.addItem(inputContainer, inputFlex); + + return flexContainer; + } + + private handleOnTextChanged(component: azdata.InputBoxComponent): boolean { + if ((!component.value)) { + // if there is no text found in the inputbox component return false + return false; + } else if ((!component.valid)) { + // if value given by user is not valid enable discard button for user + // to clear all inputs and return false + this.discardButton!.enabled = true; + return false; + } else { + // if a valid value has been entered into the input box, enable save and discard buttons + // so that user could choose to either edit instance or clear all inputs + // return true + this.saveButton!.enabled = true; + this.discardButton!.enabled = true; + return true; + } + + } + + private editWorkerNodeCount() { + let currentShards = this._postgresModel.config?.spec.scale.shards; + + this.workerBox!.min = currentShards; + this.workerBox!.placeHolder = currentShards!.toString(); + this.workerBox!.value = ''; + + this.saveArgs.workers = undefined; + } + + private createCoresMemorySection(): azdata.DivContainer { + const titleFlex = { flex: `0 1 250px` }; + + const flexContainer = this.modelView.modelBuilder.flexContainer().withLayout({ + flexWrap: 'wrap', + alignItems: 'center' + }).component(); + + const titleComponent = this.modelView.modelBuilder.text().withProperties({ + value: loc.configurationPerNode, + CSSStyles: { ...cssStyles.title, 'font-weight': 'bold', 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const titleContainer = this.modelView.modelBuilder.flexContainer().withLayout({ alignItems: 'center' }).component(); + titleContainer.addItem(titleComponent, { CSSStyles: { 'margin-right': '0px', 'margin-bottom': '15px' } }); + + const information = this.modelView.modelBuilder.button().withProperties({ + iconPath: IconPathHelper.information, + title: loc.configurationInformation, + width: '12px', + height: '12px', + enabled: false + }).component(); + + titleContainer.addItem(information, { CSSStyles: { 'margin-left': '5px', 'margin-bottom': '15px' } }); + flexContainer.addItem(titleContainer, titleFlex); + + let configurationSection = this.modelView.modelBuilder.divContainer().component(); + configurationSection.addItem(flexContainer); + + return configurationSection; + } + + private editCores() { + let currentCPUSize = this._postgresModel.config?.spec.scheduling?.default?.resources?.requests?.cpu; + + if (!currentCPUSize) { + currentCPUSize = ''; + } + + this.coresRequestBox!.placeHolder = currentCPUSize; + this.coresRequestBox!.value = ''; + this.saveArgs.coresRequest = undefined; + + currentCPUSize = this._postgresModel.config?.spec.scheduling?.default?.resources?.limits?.cpu; + + if (!currentCPUSize) { + currentCPUSize = ''; + } + + this.coresLimitBox!.placeHolder = currentCPUSize; + this.coresLimitBox!.value = ''; + this.saveArgs.coresLimit = undefined; + } + + private editMemory() { + let currentMemSizeConversion: string; + let currentMemorySize = this._postgresModel.config?.spec.scheduling?.default?.resources?.requests?.memory; + + if (!currentMemorySize) { + currentMemSizeConversion = ''; + } else { + currentMemSizeConversion = convertToGibibyteString(currentMemorySize); + } + + this.memoryRequestBox!.placeHolder = currentMemSizeConversion!; + this.memoryRequestBox!.value = ''; + + this.saveArgs.memoryRequest = undefined; + + currentMemorySize = this._postgresModel.config?.spec.scheduling?.default?.resources?.limits?.memory; + + if (!currentMemorySize) { + currentMemSizeConversion = ''; + } else { + currentMemSizeConversion = convertToGibibyteString(currentMemorySize); + } + + this.memoryLimitBox!.placeHolder = currentMemSizeConversion!; + this.memoryLimitBox!.value = ''; + + this.saveArgs.memoryLimit = undefined; + } + + private handleServiceUpdated() { + this.editWorkerNodeCount(); + this.editCores(); + this.editMemory(); + } +} diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts index b63d6218ce..0a6568ce58 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts @@ -13,6 +13,7 @@ import { PostgresConnectionStringsPage } from './postgresConnectionStringsPage'; import { Dashboard } from '../../components/dashboard'; import { PostgresDiagnoseAndSolveProblemsPage } from './postgresDiagnoseAndSolveProblemsPage'; import { PostgresSupportRequestPage } from './postgresSupportRequestPage'; +import { PostgresComputeAndStoragePage } from './postgresComputeAndStoragePage'; export class PostgresDashboard extends Dashboard { constructor(private _context: vscode.ExtensionContext, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { @@ -30,6 +31,7 @@ export class PostgresDashboard extends Dashboard { protected async registerTabs(modelView: azdata.ModelView): Promise<(azdata.DashboardTab | azdata.DashboardTabGroup)[]> { const overviewPage = new PostgresOverviewPage(modelView, this._controllerModel, this._postgresModel); const connectionStringsPage = new PostgresConnectionStringsPage(modelView, this._postgresModel); + const computeAndStoragePage = new PostgresComputeAndStoragePage(modelView, this._postgresModel); // TODO: Removed properties page while investigating bug where refreshed values don't appear in UI // const propertiesPage = new PostgresPropertiesPage(modelView, this._controllerModel, this._postgresModel); const diagnoseAndSolveProblemsPage = new PostgresDiagnoseAndSolveProblemsPage(modelView, this._context, this._postgresModel); @@ -40,7 +42,8 @@ export class PostgresDashboard extends Dashboard { { title: loc.settings, tabs: [ - connectionStringsPage.tab + connectionStringsPage.tab, + computeAndStoragePage.tab ] }, {