From 3f4e7f8c36026d6b14d7a336173a84fbc82a10b6 Mon Sep 17 00:00:00 2001 From: nasc17 <69922333+nasc17@users.noreply.github.com> Date: Wed, 30 Jun 2021 06:37:48 -0700 Subject: [PATCH] Add and drop Postgres extensions (#15923) * Works * Clean up * Aria labels added * Pr fixes, only allow one drop at a time, check for citus * Cleaning up refresh * Created separate function for creating drop button * Added with props, add comment about not able to drop citus extension * Update url link of postgres extensions to match engine version after config is availible --- extensions/arc/src/localizedConstants.ts | 11 +- .../postgres/postgresExtensionsPage.ts | 206 ++++++++++++++---- .../src/ui/dialogs/addPGExtensionsDialog.ts | 88 ++++++++ 3 files changed, 260 insertions(+), 45 deletions(-) create mode 100644 extensions/arc/src/ui/dialogs/addPGExtensionsDialog.ts diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index fa349294a4..f26e5a670a 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -37,10 +37,13 @@ export const value = localize('arc.value', "Value"); export const newInstance = localize('arc.createNew', "New Instance"); export const deleteText = localize('arc.delete', "Delete"); +export const learnMore = localize('arc.learnMore', "Learn More."); +export const dropText = localize('arc.drop', "Drop"); export const saveText = localize('arc.save', "Save"); export const discardText = localize('arc.discard', "Discard"); export const resetPassword = localize('arc.resetPassword', "Reset Password"); export const addExtensions = localize('arc.addExtensions', "Add extensions"); +export const dropExtension = localize('arc.dropExtension', "Drop extension"); export const openInAzurePortal = localize('arc.openInAzurePortal', "Open in Azure Portal"); export const resourceGroup = localize('arc.resourceGroup', "Resource Group"); export const region = localize('arc.region', "Region"); @@ -56,11 +59,13 @@ export const miaaAdmin = localize('arc.miaaAdmin', "Managed instance admin"); export const controllerEndpoint = localize('arc.controllerEndpoint', "Controller endpoint"); export const extensionName = localize('arc.extensionName', "Extension name"); export const extensionsDescription = localize('arc.extensionsDescription', "PostgreSQL provides the ability to extend the functionality of your database by using extensions. Extensions allow for bundling multiple related SQL objects together in a single package that can be loaded or removed from your database with a single command. After being loaded in the database, extensions can function like built-in features."); -export const extensionsFunction = localize('arc.extensionsFunction', "Some extensions must be loaded into PostgreSQL at startup time before they can be used. These preloaded extensions can be viewed below."); +export const extensionsFunction = localize('arc.extensionsFunction', "Some extensions must be loaded into PostgreSQL at startup time before they can be used. These preloaded extensions can be viewed and edited below."); export const extensionsLearnMore = localize('arc.extensionsLearnMore', "Learn more about PostgreSQL extensions."); export const extensionsTableLoading = localize('arc.extensionsTableLoading', "Table of preloaded extensions are loading."); export const extensionsTableLabel = localize('arc.extensionsTableLabel', "Table of preloaded extensions."); export const extensionsTableLoadingComplete = localize('arc.extensionsTableLoadingComplete', "Preloaded extensions can now be viewed."); +export const extensionsAddList = localize('arc.extensionsAddList', "Extensions"); +export const extensionsAddDialog = localize('arc.extensionsAddDialog', "PostgreSQL provides the ability to extend the functionality of your database by using extensions."); export const dataController = localize('arc.dataController', "Data controller"); export const kibanaDashboard = localize('arc.kibanaDashboard', "Kibana Dashboard"); export const grafanaDashboard = localize('arc.grafanaDashboard', "Grafana Dashboard"); @@ -217,6 +222,8 @@ export function extensionInstalled(name: string): string { return localize('arc. 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 extensionDropped(name: string): string { return localize('arc.extensionDropped', "Extension '{0}' deleted", name); } +export function extensionsAdded(name: string): string { return localize('arc.extensionsAdded', "Extensions '{0}' added", 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 dataStorage(value: string): string { return localize('arc.dataStorage', "{0} data", value); } @@ -241,6 +248,8 @@ export function copyValueToClipboard(valueName: string): string { return localiz export const pgConnectionRequired = localize('arc.pgConnectionRequired', "A connection is required to show and set database engine settings."); export const miaaConnectionRequired = localize('arc.miaaConnectionRequired', "A connection is required to list the databases on this instance."); export const couldNotFindControllerRegistration = localize('arc.couldNotFindControllerRegistration', "Could not find controller registration."); +export const dropMultipleExtensions = localize('arc.dropMultipleExtensions', "Currently dropping another extension, try again once that is completed."); +export function updateExtensionsFailed(error: any): string { return localize('arc.updateExtensionsFailed', "Editing extensions failed. {0}", getErrorMessage(error)); } export function refreshFailed(error: any): string { return localize('arc.refreshFailed', "Refresh failed. {0}", getErrorMessage(error)); } export function resetFailed(error: any): string { return localize('arc.resetFailed', "Reset failed. {0}", getErrorMessage(error)); } export function openDashboardFailed(error: any): string { return localize('arc.openDashboardFailed', "Error opening dashboard. {0}", getErrorMessage(error)); } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresExtensionsPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresExtensionsPage.ts index 082aeba2cd..bfe9a48248 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresExtensionsPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresExtensionsPage.ts @@ -10,12 +10,17 @@ import * as loc from '../../../localizedConstants'; import { IconPathHelper, cssStyles } from '../../../constants'; import { DashboardPage } from '../../components/dashboardPage'; import { PostgresModel } from '../../../models/postgresModel'; +import { AddPGExtensionsDialog } from '../../dialogs/addPGExtensionsDialog'; +import { Deferred } from '../../../common/promise'; export class PostgresExtensionsPage extends DashboardPage { - private extensions: { name: string; }[] = []; + private extensionNames: string[] = []; private extensionsTable!: azdata.DeclarativeTableComponent; private extensionsLoading!: azdata.LoadingComponent; + private addExtensionsButton!: azdata.ButtonComponent; + private _dropExtPromise?: Deferred; + private extensionsLink!: azdata.HyperlinkComponent; private readonly _azdataApi: azdataExt.IExtension; @@ -54,22 +59,22 @@ export class PostgresExtensionsPage extends DashboardPage { CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } }).component()); - const info = this.modelView.modelBuilder.text().withProperties({ + const info = this.modelView.modelBuilder.text().withProps({ value: loc.extensionsFunction, CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } }).component(); - const link = this.modelView.modelBuilder.hyperlink().withProperties({ - label: loc.extensionsLearnMore, - url: 'https://docs.microsoft.com/azure/azure-arc/data/using-extensions-in-postgresql-hyperscale-server-group', + this.extensionsLink = this.modelView.modelBuilder.hyperlink().withProps({ + label: loc.learnMore, + url: 'https://www.postgresql.org/docs/12/external-extensions.html', }).component(); const infoAndLink = this.modelView.modelBuilder.flexContainer().withLayout({ flexWrap: 'wrap' }).component(); infoAndLink.addItem(info, { CSSStyles: { 'margin-right': '5px' } }); - infoAndLink.addItem(link); + infoAndLink.addItem(this.extensionsLink); content.addItem(infoAndLink, { CSSStyles: { 'margin-bottom': '15px', 'margin-top': '25px' } }); - this.extensionsTable = this.modelView.modelBuilder.declarativeTable().withProperties({ + this.extensionsTable = this.modelView.modelBuilder.declarativeTable().withProps({ ariaLabel: loc.extensionsTableLabel, width: '100%', columns: [ @@ -77,7 +82,15 @@ export class PostgresExtensionsPage extends DashboardPage { displayName: loc.extensionName, valueType: azdata.DeclarativeDataType.string, isReadOnly: true, - width: '100%', + width: '95%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: cssStyles.tableRow + }, + { + displayName: loc.dropText, + valueType: azdata.DeclarativeDataType.component, + isReadOnly: false, + width: '10%', headerCssStyles: cssStyles.tableHeader, rowCssStyles: cssStyles.tableRow } @@ -86,8 +99,7 @@ export class PostgresExtensionsPage extends DashboardPage { }).component(); this.extensionsLoading = this.modelView.modelBuilder.loadingComponent() - .withItem(this.extensionsTable) - .withProperties({ + .withItem(this.extensionsTable).withProps({ loading: !this._postgresModel.configLastUpdated, loadingText: loc.extensionsTableLoading, loadingCompletedText: loc.extensionsTableLoadingComplete @@ -101,58 +113,164 @@ export class PostgresExtensionsPage extends DashboardPage { protected get toolbarContainer(): azdata.ToolbarContainer { // Add extensions - const addExtensionsButton = this.modelView.modelBuilder.button().withProperties({ + this.addExtensionsButton = this.modelView.modelBuilder.button().withProps({ label: loc.addExtensions, + ariaLabel: loc.addExtensions, iconPath: IconPathHelper.add }).component(); this.disposables.push( - addExtensionsButton.onDidClick(async () => { - addExtensionsButton.enabled = false; - try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: loc.updatingInstance(this._postgresModel.info.name), - cancellable: false - }, - async (_progress, _token): Promise => { - await this._azdataApi.azdata.arc.postgres.server.edit( - this._postgresModel.info.name, - { - extensions: '' - }, - this._postgresModel.controllerModel.azdataAdditionalEnvVars); + this.addExtensionsButton.onDidClick(async () => { + const addExtDialog = new AddPGExtensionsDialog(this._postgresModel); + addExtDialog.showDialog(loc.addExtensions); - try { - await this._postgresModel.refresh(); - } catch (error) { - vscode.window.showErrorMessage(loc.refreshFailed(error)); + let extArg = await addExtDialog.waitForClose(); + if (extArg) { + try { + this.addExtensionsButton.enabled = false; + let extensionList = this.extensionNames.join() + ',' + extArg; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.updatingInstance(this._postgresModel.info.name), + cancellable: false + }, + async (_progress, _token): Promise => { + + await this._azdataApi.azdata.arc.postgres.server.edit( + this._postgresModel.info.name, + { + extensions: extensionList + }, + this._postgresModel.controllerModel.azdataAdditionalEnvVars); + + try { + await this._postgresModel.refresh(); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } } - } - ); + ); - vscode.window.showInformationMessage(loc.instanceUpdated(this._postgresModel.info.name)); + vscode.window.showInformationMessage(loc.extensionsAdded(extensionList)); - } catch (error) { - vscode.window.showErrorMessage(loc.instanceUpdateFailed(this._postgresModel.info.name, error)); - } finally { - addExtensionsButton.enabled = true; + } catch (error) { + vscode.window.showErrorMessage(loc.updateExtensionsFailed(error)); + } finally { + this.addExtensionsButton.enabled = true; + } } })); - return this.modelView.modelBuilder.toolbarContainer().component(); + return this.modelView.modelBuilder.toolbarContainer().withToolbarItems([ + { component: this.addExtensionsButton } + ]).component(); } private refreshExtensionsTable(): void { - if (this._postgresModel.config) { - this.extensions = this._postgresModel.config?.spec.engine.extensions; - this.extensionsTable.data = this.extensions.map(e => [e.name]); + let extensions = this._postgresModel.config!.spec.engine.extensions; + this.extensionsTable.data = extensions.map(e => { + + this.extensionNames.push(e.name); + + return [e.name, this.createDropButton(e.name)]; + }); + } + + /** + * Creates drop button to add to each row of extensions table. + * Allows user to drop individual extension. + * @param name name of postgres extension the drop button will be tied to. + */ + public createDropButton(name: string): azdata.ButtonComponent { + // Can drop individual extensions + let button = this.modelView.modelBuilder.button().withProps({ + iconPath: IconPathHelper.delete, + ariaLabel: loc.dropExtension, + title: loc.dropExtension, + width: '20px', + height: '20px', + enabled: true + }).component(); + + this.disposables.push( + button.onDidClick(async () => { + try { + this.addExtensionsButton.enabled = false; + button.enabled = false; + await this.dropExtension(name); + + try { + await this._postgresModel.refresh(); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } + + vscode.window.showInformationMessage(loc.extensionDropped(name)); + + } catch (error) { + vscode.window.showErrorMessage(loc.updateExtensionsFailed(error)); + } finally { + this.addExtensionsButton.enabled = true; + } + }) + ); + + // Dropping the citus extension is not supported. + if (name === 'citus') { + button.enabled = false; + } + + return button; + } + + /** + * Calls edit on postgres extensions with an updated extensions list. + * @param name name of postgres extension to not inlcude when editing list of extensions + */ + public async dropExtension(name: string): Promise { + // Only allow one drop to be happening at a time + if (this._dropExtPromise) { + vscode.window.showErrorMessage(loc.dropMultipleExtensions); + return this._dropExtPromise.promise; + } + + this._dropExtPromise = new Deferred(); + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.updatingInstance(this._postgresModel.info.name), + cancellable: false + }, + async (_progress, _token): Promise => { + let index = this.extensionNames.indexOf(name, 0); + this.extensionNames.splice(index, 1); + + await this._azdataApi.azdata.arc.postgres.server.edit( + this._postgresModel.info.name, + { + extensions: this.extensionNames.join() + }, + this._postgresModel.controllerModel.azdataAdditionalEnvVars + ); + } + ); + this._dropExtPromise.resolve(); + } catch (err) { + this._dropExtPromise.reject(err); + throw err; + } finally { + this._dropExtPromise = undefined; } } private handleConfigUpdated(): void { - this.extensionsLoading.loading = false; - this.refreshExtensionsTable(); + if (this._postgresModel.config) { + this.extensionsLoading.loading = false; + this.extensionsLink.url = `https://www.postgresql.org/docs/${this._postgresModel.engineVersion}/external-extensions.html`; + this.extensionNames = []; + this.refreshExtensionsTable(); + } } } diff --git a/extensions/arc/src/ui/dialogs/addPGExtensionsDialog.ts b/extensions/arc/src/ui/dialogs/addPGExtensionsDialog.ts new file mode 100644 index 0000000000..f4c8d23ca3 --- /dev/null +++ b/extensions/arc/src/ui/dialogs/addPGExtensionsDialog.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import { Deferred } from '../../common/promise'; +import * as loc from '../../localizedConstants'; +import { cssStyles } from '../../constants'; +import { InitializingComponent } from '../components/initializingComponent'; +import { PostgresModel } from '../../models/postgresModel'; + +export class AddPGExtensionsDialog extends InitializingComponent { + protected modelBuilder!: azdata.ModelBuilder; + + protected extensionsListInputBox!: azdata.InputBoxComponent; + + protected _completionPromise = new Deferred(); + + constructor(protected _model: PostgresModel) { + super(); + } + + public showDialog(dialogTitle: string): azdata.window.Dialog { + const dialog = azdata.window.createModelViewDialog(dialogTitle); + dialog.cancelButton.onClick(() => this.handleCancel()); + dialog.registerContent(async view => { + this.modelBuilder = view.modelBuilder; + + const info = this.modelBuilder.text().withProperties({ + value: loc.extensionsFunction, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const link = this.modelBuilder.hyperlink().withProperties({ + label: loc.extensionsLearnMore, + url: 'https://docs.microsoft.com/azure/azure-arc/data/using-extensions-in-postgresql-hyperscale-server-group', + }).component(); + + const infoAndLink = this.modelBuilder.flexContainer().withLayout({ flexWrap: 'wrap' }).component(); + infoAndLink.addItem(info, { CSSStyles: { 'margin-right': '5px' } }); + infoAndLink.addItem(link); + + this.extensionsListInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: '', + ariaLabel: loc.extensionsAddList, + enabled: true + }).component(); + + let formModel = this.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: infoAndLink + }, + { + component: this.extensionsListInputBox, + title: loc.extensionsAddList + } + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await view.initializeModel(formModel); + this.extensionsListInputBox.focus(); + this.initialized = true; + }); + + dialog.registerCloseValidator(async () => await this.validate()); + dialog.okButton.label = loc.addExtensions; + dialog.cancelButton.label = loc.cancel; + azdata.window.openDialog(dialog); + return dialog; + } + + public async validate(): Promise { + this._completionPromise.resolve(this.extensionsListInputBox.value); + return true; + } + + private handleCancel(): void { + this._completionPromise.resolve(undefined); + } + + public waitForClose(): Promise { + return this._completionPromise.promise; + } +}