diff --git a/extensions/arc/images/pitr.svg b/extensions/arc/images/pitr.svg new file mode 100644 index 0000000000..0b3cc89f2e --- /dev/null +++ b/extensions/arc/images/pitr.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + Icon-manage-315 + + + + \ No newline at end of file diff --git a/extensions/arc/src/constants.ts b/extensions/arc/src/constants.ts index 38622a4961..931049d3cd 100644 --- a/extensions/arc/src/constants.ts +++ b/extensions/arc/src/constants.ts @@ -34,6 +34,7 @@ export class IconPathHelper { public static backup: IconPath; public static properties: IconPath; public static networking: IconPath; + public static pitr: IconPath; public static refresh: IconPath; public static reset: IconPath; public static support: IconPath; @@ -155,6 +156,10 @@ export class IconPathHelper { light: context.asAbsolutePath('images/gear-colored-gray.svg'), dark: context.asAbsolutePath('images/gear-colored-gray.svg'), }; + IconPathHelper.pitr = { + light: context.asAbsolutePath('images/pitr.svg'), + dark: context.asAbsolutePath('images/pitr.svg'), + }; } } diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 6eb8d87d82..d4761ff8cd 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -24,6 +24,7 @@ export const properties = localize('arc.properties', "Properties"); export const settings = localize('arc.settings', "Settings"); export const security = localize('arc.security', "Security"); export const computeAndStorage = localize('arc.computeAndStorage', "Compute + Storage"); +export const backups = localize('arc.backups', "Backups"); export const coordinatorNodeParameters = localize('arc.coordinatorNodeParameters', "Coordinator Node Parameters"); export const workerNodeParameters = localize('arc.workerNodeParameters', "Worker Node Parameters"); export const compute = localize('arc.compute', "Compute"); @@ -34,7 +35,6 @@ export const supportAndTroubleshooting = localize('arc.supportAndTroubleshooting export const resourceHealth = localize('arc.resourceHealth', "Resource health"); export const parameterName = localize('arc.parameterName', "Parameter Name"); 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."); @@ -43,12 +43,14 @@ export const saveText = localize('arc.save', "Save"); export const discardText = localize('arc.discard', "Discard"); export const resetPassword = localize('arc.resetPassword', "Reset Password"); export const loadExtensions = localize('arc.loadExtensions', "Load extensions"); +export const configureRP = localize('arc.configureRP', "Configure retention policy"); export const unloadExtensions = localize('arc.unloadExtensions', "Unload extensions"); export const noExtensions = localize('arc.noExtensions', "No extensions listed in configuration."); 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"); export const subscriptionId = localize('arc.subscriptionId', "Subscription ID"); +export const subscription = localize('arc.subscription', "Subscription"); export const state = localize('arc.state', "State"); export const connectionMode = localize('arc.connectionMode', "Connection Mode"); export const namespace = localize('arc.namespace', "Namespace"); @@ -56,6 +58,21 @@ export const externalEndpoint = localize('arc.externalEndpoint', "External Endpo export const name = localize('arc.name', "Name"); export const type = localize('arc.type', "Type"); export const status = localize('arc.status', "Status"); +export const database = localize('arc.database', "Database"); +export const sourceDatabase = localize('arc.sourceDatabase', "Source database"); +export const earliestPitrRestorePoint = localize('arc.earliestPitrRestorePoint', "Earliest PITR restore point"); +export const latestpitrRestorePoint = localize('arc.latestpitrRestorePoint', "Latest PITR restore point"); +export const pitr = localize('arc.pitr', "Point-in-time restore (PITR)"); +export const projectDetails = localize('arc.projectDetails', "Project Details"); +export const projectDetailsText = localize('arc.projectDetailsText', "Select the subscription to manage deployed resources. Use resource groups like folders to organize and manage all your resources."); +export const sourceDetails = localize('arc.sourceDetails', "Source Details"); +export const sourceDetailsText = localize('arc.sourceDetailsText', "Select a backup source and provide details. Additional settings will be defaulted where possible based on the selected backup."); +export const databaseDetails = localize('arc.databaseDetails', "Database Details"); +export const databaseDetailsText = localize('arc.databaseDetailsText', "Enter the required settings for this database, including a name and a target managed instance. By default, the source instance is selected."); +export const restore = localize('arc.restore', "Restore"); +export const instance = localize('arc.instance', "Instance"); +export const restorePoint = localize('arc.restorePoint', "Restore point (UTC)"); +export const restoreDatabase = localize('arc.restoreDatabase', "Restore Database"); export const miaaAdmin = localize('arc.miaaAdmin', "Managed instance admin"); 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."); @@ -91,6 +108,7 @@ export const workerOneNodeValidationMessage = localize('arc.workerOneNodeValidat export const vCores = localize('arc.vCores', "vCores"); export const ram = localize('arc.ram', "RAM"); export const refresh = localize('arc.refresh', "Refresh"); +export const configureRetentionPolicyButton = localize('arc.configureRetentionPolicyButton', "Configure Retention Policy"); export const resetAllToDefault = localize('arc.resetAllToDefault', "Reset all to default"); export const resetToDefault = localize('arc.resetToDefault', "Reset to default"); export const troubleshoot = localize('arc.troubleshoot', "Troubleshoot"); @@ -130,6 +148,7 @@ export const password = localize('arc.password', "Password"); export const rememberPassword = localize('arc.rememberPassword', "Remember Password"); export const connect = localize('arc.connect', "Connect"); export const cancel = localize('arc.cancel', "Cancel"); +export const apply = localize('arc.apply', "Apply"); export const ok = localize('arc.ok', "Ok"); export const on = localize('arc.on', "On"); export const off = localize('arc.off', "Off"); @@ -171,6 +190,9 @@ export const searchToFilter = localize('arc.searchToFilter', "Search to filter i export const scalingCompute = localize('arc.scalingCompute', "scaling compute vCores and memory."); export const postgresComputeAndStorageDescriptionPartOne = localize('arc.postgresComputeAndStorageDescriptionPartOne', "You can scale your Azure Arc-enabled"); export const miaaComputeAndStorageDescriptionPartOne = localize('arc.miaaComputeAndStorageDescriptionPartOne', "You can scale your Azure SQL managed instance - Azure Arc by"); +export const miaaBackupsDatabasesDescription = localize('arc.miaaBackupsDatabasesDescription', "Databases with available backups are displayed below. Restore databases to this instance or any other instance within the same custom location."); +export const pitrInfo = localize('arc.pitrInfo', "Specify how long you want to keep your point-in-time backups. Customize this for backup availability."); +export const restoreInfo = localize('arc.restoreInfo', "Restore a database to an Azure Arc enabled SQL Managed Instance of your choice."); export const postgresComputeAndStorageDescriptionPartTwo = localize('arc.postgres.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"); @@ -192,6 +214,7 @@ export const coresRequest = localize('arc.coresRequest', "CPU request"); export const workerCoresRequest = localize('arc.workerCoresRequest', "Worker Nodes CPU request"); export const coordinatorCoresRequest = localize('arc.coordinatorCoresRequest', "Coordinator Node CPU request"); export const memoryLimit = localize('arc.memoryLimit', "Memory limit (in GB)"); +export const retentionDays = localize('arc.retentionDays', "Point-In-Time Recovery retention (days)"); export const workerMemoryLimit = localize('arc.workerMemoryLimit', "Worker Nodes Memory limit (in GB)"); export const coordinatorMemoryLimit = localize('arc.coordinatorMemoryLimit', "Coordinator Node Memory limit (in GB)"); export const memoryRequest = localize('arc.memoryRequest', "Memory request (in GB)"); diff --git a/extensions/arc/src/models/miaaModel.ts b/extensions/arc/src/models/miaaModel.ts index 8362f5c340..60a44c3b95 100644 --- a/extensions/arc/src/models/miaaModel.ts +++ b/extensions/arc/src/models/miaaModel.ts @@ -16,13 +16,23 @@ import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider'; import { ControllerModel, Registration } from './controllerModel'; import { ResourceModel } from './resourceModel'; -export type DatabaseModel = { name: string, status: string }; - +export type DatabaseModel = { name: string, status: string, lastBackup: string }; +export type RPModel = { recoveryPointObjective: string, retentionDays: string }; +export type PITRModel = { + instanceName: string, + resourceGroupName: string, + location: string, + subscriptionId: string, + dbName: string, + restorePoint: string, + earliestPitr: string, + latestPitr: string, +}; +export const systemDbs = ['master', 'msdb', 'tempdb', 'model']; export class MiaaModel extends ResourceModel { private _config: azExt.SqlMiShowResult | undefined; private _databases: DatabaseModel[] = []; - private readonly _onConfigUpdated = new vscode.EventEmitter(); private readonly _onDatabasesUpdated = new vscode.EventEmitter(); private readonly _azApi: azExt.IExtension; @@ -30,6 +40,10 @@ export class MiaaModel extends ResourceModel { public onDatabasesUpdated = this._onDatabasesUpdated.event; public configLastUpdated: Date | undefined; public databasesLastUpdated: Date | undefined; + public rpSettings: RPModel = { + recoveryPointObjective: '', + retentionDays: '' + }; private _refreshPromise: Deferred | undefined = undefined; @@ -76,6 +90,7 @@ export class MiaaModel extends ResourceModel { const result = await this._azApi.az.sql.miarc.show(this.info.name, this.controllerModel.info.namespace, this.controllerModel.azAdditionalEnvVars); this._config = result.stdout; this.configLastUpdated = new Date(); + this.rpSettings.retentionDays = this._config?.spec?.backup?.retentionPeriodInDays?.toString() ?? ''; this._onConfigUpdated.fire(this._config); } catch (err) { // If an error occurs show a message so the user knows something failed but still @@ -111,6 +126,18 @@ export class MiaaModel extends ResourceModel { } } + public async callGetDatabases(): Promise { + try { + await this.getDatabases(); + } catch (error) { + if (error instanceof UserCancelledError) { + vscode.window.showWarningMessage(loc.miaaConnectionRequired); + } else { + vscode.window.showErrorMessage(loc.fetchDatabasesFailed(this.info.name, error)); + } + throw error; + } + } public async getDatabases(promptForConnection: boolean = true): Promise { if (!this._connectionProfile) { await this.getConnectionProfile(promptForConnection); @@ -132,9 +159,9 @@ export class MiaaModel extends ResourceModel { throw new Error('Could not fetch databases'); } if (databases.length > 0 && typeof (databases[0]) === 'object') { - this._databases = (databases).map(db => { return { name: db.options['name'], status: db.options['state'] }; }); + this._databases = (databases).map(db => { return { name: db.options['name'], status: db.options['state'], lastBackup: db.options['lastBackup'] }; }); } else { - this._databases = (databases).map(db => { return { name: db, status: '-' }; }); + this._databases = (databases).map(db => { return { name: db, status: '-', lastBackup: '' }; }); } this.databasesLastUpdated = new Date(); this._onDatabasesUpdated.fire(this._databases); @@ -178,4 +205,5 @@ export class MiaaModel extends ResourceModel { this._miaaInfo.userName = connectionProfile.userName; await this._treeDataProvider.saveControllers(); } + } diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaBackupsPage.ts b/extensions/arc/src/ui/dashboards/miaa/miaaBackupsPage.ts new file mode 100644 index 0000000000..4a63790b90 --- /dev/null +++ b/extensions/arc/src/ui/dashboards/miaa/miaaBackupsPage.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * 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 azExt from 'az-ext'; +import * as loc from '../../../localizedConstants'; +import { IconPathHelper, cssStyles } from '../../../constants'; +import { DashboardPage } from '../../components/dashboardPage'; +import { MiaaModel, RPModel, DatabaseModel, systemDbs } from '../../../models/miaaModel'; +import { ControllerModel } from '../../../models/controllerModel'; +import { ConfigureRPOSqlDialog } from '../../dialogs/configureRPOSqlDialog'; +import { RestoreSqlDialog } from '../../dialogs/restoreSqlDialog'; + +export class MiaaBackupsPage extends DashboardPage { + constructor(modelView: azdata.ModelView, dashboard: azdata.window.ModelViewDashboard, private _controllerModel: ControllerModel, private _miaaModel: MiaaModel) { + super(modelView, dashboard); + this._azApi = vscode.extensions.getExtension(azExt.extension.name)?.exports; + this.disposables.push( + this._miaaModel.onDatabasesUpdated(() => this.eventuallyRunOnInitialized(() => this.handleDatabasesUpdated())), + this._miaaModel.onConfigUpdated(() => this.eventuallyRunOnInitialized(() => this.handleDatabasesUpdated())) + ); + } + private _databasesContainer!: azdata.DivContainer; + private _configureRetentionPolicyButton!: azdata.ButtonComponent; + private _connectToServerLoading!: azdata.LoadingComponent; + private _connectToServerButton!: azdata.ButtonComponent; + private _databasesTableLoading!: azdata.LoadingComponent; + private _databasesTable!: azdata.DeclarativeTableComponent; + private _databasesMessage!: azdata.TextComponent; + private readonly _azApi: azExt.IExtension; + + public saveArgs: RPModel = { + recoveryPointObjective: '', + retentionDays: '' + }; + + public pitrArgs = { + destName: '', + managedInstance: '', + time: '', + noWait: true + }; + + public get title(): string { + return loc.backups; + } + + public get id(): string { + return 'backups'; + } + + public get icon(): { dark: string, light: string } { + return IconPathHelper.pitr; + } + protected async refresh(): Promise { + await Promise.all([this._controllerModel.refresh(false, this._controllerModel.info.namespace), this._miaaModel.refresh()]); + } + + public get container(): azdata.Component { + const root = this.modelView.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withProps({ CSSStyles: { 'margin': '18px' } }) + .component(); + const content = this.modelView.modelBuilder.divContainer().component(); + this._databasesContainer = this.modelView.modelBuilder.divContainer().component(); + root.addItem(content, { CSSStyles: { 'margin': '20px' } }); + const databaseTitle = this.modelView.modelBuilder.text().withProps({ + value: loc.databases, + CSSStyles: { ...cssStyles.title }, + }).component(); + + content.addItem(databaseTitle); + const infoBackupDatabases = this.modelView.modelBuilder.text().withProps({ + value: loc.miaaBackupsDatabasesDescription, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px', 'max-width': 'auto' } + }).component(); + const backupInfoDescription = this.modelView.modelBuilder.flexContainer() + .withLayout({ flexWrap: 'wrap' }) + .withItems([ + infoBackupDatabases + ], { CSSStyles: { 'margin-right': '5px' } }).component(); + + const backupsDbsLearnMoreLink = this.modelView.modelBuilder.hyperlink().withProps({ + label: loc.learnMore, + url: 'https://docs.microsoft.com/azure/azure-arc/data/point-in-time-restore', + CSSStyles: { 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const backupDatabaseInfoAndLink = this.modelView.modelBuilder.flexContainer() + .withLayout({ flexWrap: 'wrap' }) + .withItems([ + backupInfoDescription, + backupsDbsLearnMoreLink + ], { CSSStyles: { 'margin-right': '5px' } }).component(); + + content.addItem(backupDatabaseInfoAndLink, { CSSStyles: { 'min-height': '30px' } }); + + // Create loaded components + const connectToServerText = this.modelView.modelBuilder.text().withProps({ + value: loc.miaaConnectionRequired + }).component(); + + this._connectToServerButton = this.modelView.modelBuilder.button().withProps({ + label: loc.connectToServer, + enabled: false, + CSSStyles: { 'max-width': '125px', 'margin-left': '40%' } + }).component(); + + const connectToServerContainer = this.modelView.modelBuilder.divContainer().component(); + + connectToServerContainer.addItem(connectToServerText, { CSSStyles: { 'text-align': 'center', 'margin-top': '20px' } }); + connectToServerContainer.addItem(this._connectToServerButton); + + this._connectToServerLoading = this.modelView.modelBuilder.loadingComponent().withItem(connectToServerContainer).component(); + + this._databasesContainer.addItem(this._connectToServerLoading, { CSSStyles: { 'margin-top': '20px' } }); + + this._databasesTableLoading = this.modelView.modelBuilder.loadingComponent().component(); + this._databasesTable = this.modelView.modelBuilder.declarativeTable().withProps({ + width: '100%', + columns: [ + { + displayName: loc.database, + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '30%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: cssStyles.tableRow + }, + { + displayName: loc.latestpitrRestorePoint, + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '50%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: cssStyles.tableRow + }, + { + displayName: loc.restore, + valueType: azdata.DeclarativeDataType.component, + isReadOnly: true, + width: '20%', + headerCssStyles: cssStyles.tableHeader, + rowCssStyles: cssStyles.tableRow, + } + ], + dataValues: [] + }).component(); + + this._databasesMessage = this.modelView.modelBuilder.text() + .withProps({ CSSStyles: { 'text-align': 'center' } }) + .component(); + + this.handleDatabasesUpdated(); + this._databasesTableLoading.component = this._databasesTable; + this.disposables.push( + this._connectToServerButton!.onDidClick(async () => { + this._connectToServerButton!.enabled = false; + this._databasesTableLoading!.loading = true; + try { + await this._miaaModel.callGetDatabases(); + } catch { + this._connectToServerButton!.enabled = true; + } + }) + ); + root.addItem(this._databasesContainer); + root.addItem(this._databasesMessage); + + this.initialized = true; + return root; + } + + public get toolbarContainer(): azdata.ToolbarContainer { + // Refresh + const refreshButton = this.modelView.modelBuilder.button().withProps({ + label: loc.refresh, + iconPath: IconPathHelper.refresh + }).component(); + this.disposables.push( + refreshButton.onDidClick(async () => { + refreshButton.enabled = false; + try { + await this.refresh(); + } finally { + refreshButton.enabled = true; + } + })); + this._configureRetentionPolicyButton = this.modelView.modelBuilder.button().withProps({ + label: loc.configureRetentionPolicyButton, + enabled: true, + iconPath: IconPathHelper.edit, + }).component(); + this.disposables.push( + this._configureRetentionPolicyButton.onDidClick(async () => { + const retentionPolicySqlDialog = new ConfigureRPOSqlDialog(this._miaaModel); + this.refreshRD(); + retentionPolicySqlDialog.showDialog(loc.configureRP, this.saveArgs.retentionDays); + + let rpArg = await retentionPolicySqlDialog.waitForClose(); + if (rpArg) { + try { + this._configureRetentionPolicyButton.enabled = false; + this.saveArgs.retentionDays = rpArg.retentionDays; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.updatingInstance(this._miaaModel.info.name), + cancellable: false + }, + async (_progress, _token): Promise => { + await this._azApi.az.sql.miarc.edit( + this._miaaModel.info.name, this.saveArgs, this._miaaModel.controllerModel.info.namespace, this._miaaModel.controllerModel.azAdditionalEnvVars); + + try { + await this._miaaModel.refresh(); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } + } + ); + + } catch (error) { + vscode.window.showErrorMessage(loc.updateExtensionsFailed(error)); + } finally { + this._configureRetentionPolicyButton.enabled = true; + } + } + })); + + return this.modelView.modelBuilder.toolbarContainer().withToolbarItems( + [ + { component: refreshButton, toolbarSeparatorAfter: true }, + { component: this._configureRetentionPolicyButton, toolbarSeparatorAfter: false }, + + ] + ).component(); + } + + private handleDatabasesUpdated(): void { + // If we were able to get the databases it means we have a good connection so update the username too + let databaseDisplay = this._miaaModel.databases.map(d => [ + d.name, + d.lastBackup, + this.createRestoreButton(d)]); + let databasesValues = databaseDisplay.map(d => { + return d.map((value): azdata.DeclarativeTableCellValue => { + return { value: value }; + }); + }); + this._databasesTable.setDataValues(databasesValues); + + this._databasesTableLoading.loading = false; + + if (this._miaaModel.databasesLastUpdated) { + // We successfully connected so now can remove the button and replace it with the actual databases table + this._databasesContainer.removeItem(this._connectToServerLoading); + this._databasesContainer.addItem(this._databasesTableLoading, { CSSStyles: { 'margin-bottom': '20px' } }); + + } else { + // If we don't have an endpoint then there's no point in showing the connect button - but the logic + // to display text informing the user of this is already handled by the handleMiaaConfigUpdated + if (this._miaaModel?.config?.status.primaryEndpoint) { + this._connectToServerLoading.loading = false; + this._connectToServerButton.enabled = true; + } + } + } + + private refreshRD(): void { + this.saveArgs.retentionDays = this._miaaModel.config?.spec?.backup?.retentionPeriodInDays.toString() ?? ''; + } + + // Create restore button for every database entry in the database table + private createRestoreButton(db: DatabaseModel): azdata.ButtonComponent | string { + let pitrDate = db.lastBackup; + if (!pitrDate) { + return ''; + } + const restoreButton = this.modelView.modelBuilder.button().withProps({ + enabled: systemDbs.indexOf(db.name) > -1 ? false : true, + iconPath: IconPathHelper.openInTab, + }).component(); + this.disposables.push( + restoreButton.onDidClick(async () => { + const restoreDialog = new RestoreSqlDialog(this._miaaModel, this._controllerModel, db); + restoreDialog.showDialog(loc.restoreDatabase); + let args = await restoreDialog.waitForClose(); + if (args) { + try { + restoreButton.enabled = false; + this.pitrArgs.destName = args.dbName; + this.pitrArgs.managedInstance = args.instanceName; + this.pitrArgs.time = `"${args.restorePoint}"`; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: loc.updatingInstance(this._miaaModel.info.name), + cancellable: false + }, + async (_progress, _token): Promise => { + await this._azApi.az.sql.midbarc.restore( + db.name, this.pitrArgs, this._miaaModel.controllerModel.info.namespace, this._miaaModel.controllerModel.azAdditionalEnvVars); + try { + await this._miaaModel.refresh(); + } catch (error) { + vscode.window.showErrorMessage(loc.refreshFailed(error)); + } + } + ); + } catch (error) { + vscode.window.showErrorMessage(loc.updateExtensionsFailed(error)); + } finally { + this._configureRetentionPolicyButton.enabled = true; + } + } + })); + return restoreButton; + } +} diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaDashboard.ts b/extensions/arc/src/ui/dashboards/miaa/miaaDashboard.ts index 67601e386a..ba2fb32da6 100644 --- a/extensions/arc/src/ui/dashboards/miaa/miaaDashboard.ts +++ b/extensions/arc/src/ui/dashboards/miaa/miaaDashboard.ts @@ -11,6 +11,7 @@ import * as loc from '../../../localizedConstants'; import { MiaaConnectionStringsPage } from './miaaConnectionStringsPage'; import { MiaaModel } from '../../../models/miaaModel'; import { MiaaComputeAndStoragePage } from './miaaComputeAndStoragePage'; +import { MiaaBackupsPage } from './miaaBackupsPage'; export class MiaaDashboard extends Dashboard { @@ -29,13 +30,15 @@ export class MiaaDashboard extends Dashboard { const overviewPage = new MiaaDashboardOverviewPage(modelView, this.dashboard, this._controllerModel, this._miaaModel); const connectionStringsPage = new MiaaConnectionStringsPage(modelView, this.dashboard, this._miaaModel); const computeAndStoragePage = new MiaaComputeAndStoragePage(modelView, this.dashboard, this._miaaModel); + const miaaBackupsPage = new MiaaBackupsPage(modelView, this.dashboard, this._controllerModel, this._miaaModel); return [ overviewPage.tab, { title: loc.settings, tabs: [ connectionStringsPage.tab, - computeAndStoragePage.tab + computeAndStoragePage.tab, + miaaBackupsPage.tab ] }, ]; diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts b/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts index 95b9bbd489..edf00834bc 100644 --- a/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts +++ b/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts @@ -14,7 +14,6 @@ import { ControllerModel } from '../../../models/controllerModel'; import { MiaaModel } from '../../../models/miaaModel'; import { DashboardPage } from '../../components/dashboardPage'; import { ResourceType } from 'arc'; -import { UserCancelledError } from '../../../common/api'; export class MiaaDashboardOverviewPage extends DashboardPage { @@ -212,7 +211,7 @@ export class MiaaDashboardOverviewPage extends DashboardPage { this._connectToServerButton!.enabled = false; this._databasesTableLoading!.loading = true; try { - await this.callGetDatabases(); + await this._miaaModel.callGetDatabases(); } catch { this._connectToServerButton!.enabled = true; } @@ -322,19 +321,6 @@ export class MiaaDashboardOverviewPage extends DashboardPage { ).component(); } - private async callGetDatabases(): Promise { - try { - await this._miaaModel.getDatabases(); - } catch (error) { - if (error instanceof UserCancelledError) { - vscode.window.showWarningMessage(loc.miaaConnectionRequired); - } else { - vscode.window.showErrorMessage(loc.fetchDatabasesFailed(this._miaaModel.info.name, error)); - } - throw error; - } - } - private handleRegistrationsUpdated(): void { const config = this._controllerModel.controllerConfig; if (this._openInAzurePortalButton) { diff --git a/extensions/arc/src/ui/dialogs/configureRPOSqlDialog.ts b/extensions/arc/src/ui/dialogs/configureRPOSqlDialog.ts new file mode 100644 index 0000000000..1e9df7682e --- /dev/null +++ b/extensions/arc/src/ui/dialogs/configureRPOSqlDialog.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { MiaaModel, RPModel } from '../../models/miaaModel'; + +export class ConfigureRPOSqlDialog extends InitializingComponent { + protected modelBuilder!: azdata.ModelBuilder; + protected retentionDaysInputBox!: azdata.InputBoxComponent; + protected _completionPromise = new Deferred(); + public saveArgs: RPModel = { + recoveryPointObjective: '', + retentionDays: '' + }; + + constructor(protected _model: MiaaModel) { + super(); + } + + public showDialog(dialogTitle: string, retentionDays: string | undefined): azdata.window.Dialog { + const dialog = azdata.window.createModelViewDialog(dialogTitle); + dialog.cancelButton.onClick(() => this.handleCancel()); + retentionDays = (retentionDays === undefined ? this._model.config?.spec?.backup?.retentionPeriodInDays?.toString() : retentionDays); + dialog.registerContent(async view => { + this.modelBuilder = view.modelBuilder; + this.retentionDaysInputBox = this.modelBuilder.inputBox() + .withProps({ + readOnly: false, + min: 1, + max: 35, + inputType: 'number', + ariaLabel: loc.retentionDays, + value: retentionDays + }).component(); + + const info = this.modelBuilder.text().withProps({ + value: loc.pitrInfo, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const link = this.modelBuilder.hyperlink().withProps({ + label: loc.learnMore, + url: 'https://docs.microsoft.com/azure/azure-arc/data/point-in-time-restore', + }).component(); + + const infoAndLink = this.modelBuilder.flexContainer().withLayout({ flexWrap: 'wrap' }).component(); + infoAndLink.addItem(this.modelBuilder.text().withProps({ + value: loc.pitr, + CSSStyles: { ...cssStyles.title } + }).component()); + infoAndLink.addItem(info, { CSSStyles: { 'margin-right': '5px' } }); + infoAndLink.addItem(link); + + let formModel = this.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: infoAndLink + }, + { + component: this.retentionDaysInputBox, + title: loc.retentionDays, + required: false + } + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await view.initializeModel(formModel); + this.retentionDaysInputBox.focus(); + this.initialized = true; + }); + + dialog.okButton.label = loc.apply; + dialog.cancelButton.label = loc.cancel; + dialog.registerCloseValidator(async () => await this.validate()); + dialog.okButton.onClick(() => { + this.saveArgs.retentionDays = this.retentionDaysInputBox.value ?? ''; + this._completionPromise.resolve(this.saveArgs); + }); + azdata.window.openDialog(dialog); + return dialog; + } + + public async validate(): Promise { + return !!this.retentionDaysInputBox.value; + } + + private handleCancel(): void { + this._completionPromise.resolve(undefined); + } + + public waitForClose(): Promise { + return this._completionPromise.promise; + } +} diff --git a/extensions/arc/src/ui/dialogs/restoreSqlDialog.ts b/extensions/arc/src/ui/dialogs/restoreSqlDialog.ts new file mode 100644 index 0000000000..8211ace880 --- /dev/null +++ b/extensions/arc/src/ui/dialogs/restoreSqlDialog.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as vscode from 'vscode'; +import { cssStyles } from '../../constants'; +import { InitializingComponent } from '../components/initializingComponent'; +import { MiaaModel, PITRModel, DatabaseModel } from '../../models/miaaModel'; +import * as azurecore from 'azurecore'; +import { ControllerModel } from '../../models/controllerModel'; + +export class RestoreSqlDialog extends InitializingComponent { + protected modelBuilder!: azdata.ModelBuilder; + private pitrSettings: PITRModel = { + instanceName: '-', + resourceGroupName: '-', + location: '-', + subscriptionId: '-', + dbName: '-', + restorePoint: '-', + earliestPitr: '-', + latestPitr: '-', + }; + + private earliestRestorePointInputBox!: azdata.InputBoxComponent; + private latestRestorePointInputBox!: azdata.InputBoxComponent; + private subscriptionInputBox!: azdata.InputBoxComponent; + private resourceGroupInputBox!: azdata.InputBoxComponent; + private sourceDbInputBox!: azdata.InputBoxComponent; + private restorePointInputBox!: azdata.InputBoxComponent; + private databaseNameInputBox!: azdata.InputBoxComponent; + private instanceInputBox!: azdata.InputBoxComponent; + protected _completionPromise = new Deferred(); + private _azurecoreApi: azurecore.IExtension; + constructor(protected _miaaModel: MiaaModel, protected _controllerModel: ControllerModel, protected _database: DatabaseModel) { + super(); + this._azurecoreApi = vscode.extensions.getExtension(azurecore.extension.name)?.exports; + + } + + public showDialog(dialogTitle: string): azdata.window.Dialog { + const dialog = azdata.window.createModelViewDialog(dialogTitle, dialogTitle, 'normal'); + dialog.cancelButton.onClick(() => this.handleCancel()); + dialog.registerContent(async view => { + this.modelBuilder = view.modelBuilder; + this.refreshPitrSettings(); + const pitrTitle = this.modelBuilder.text().withProps({ + value: loc.pitr, + CSSStyles: { ...cssStyles.title } + }).component(); + const projectDetailsTitle = this.modelBuilder.text().withProps({ + value: loc.projectDetails, + CSSStyles: { ...cssStyles.title } + }).component(); + const projectDetailsTextLabel = this.modelBuilder.text().withProps({ + value: loc.projectDetailsText, + }).component(); + this.subscriptionInputBox = this.modelBuilder.inputBox() + .withProps({ + enabled: false, + ariaLabel: loc.subscription, + value: this.pitrSettings.subscriptionId + }).component(); + this.resourceGroupInputBox = this.modelBuilder.inputBox() + .withProps({ + enabled: false, + ariaLabel: loc.resourceGroup, + value: this.pitrSettings.resourceGroupName + }).component(); + + const sourceDetailsTitle = this.modelBuilder.text().withProps({ + value: loc.sourceDetails, + CSSStyles: { ...cssStyles.title } + }).component(); + const sourceDetailsTextLabel = this.modelBuilder.text().withProps({ + value: loc.sourceDetailsText, + }).component(); + this.sourceDbInputBox = this.modelBuilder.inputBox() + .withProps({ + enabled: false, + ariaLabel: loc.sourceDatabase, + value: this._database.name + }).component(); + + this.earliestRestorePointInputBox = this.modelBuilder.inputBox() + .withProps({ + enabled: false, + ariaLabel: loc.earliestPitrRestorePoint, + value: '' + }).component(); + + this.latestRestorePointInputBox = this.modelBuilder.inputBox() + .withProps({ + enabled: false, + ariaLabel: loc.latestpitrRestorePoint, + value: this._database.lastBackup + }).component(); + + this.restorePointInputBox = this.modelBuilder.inputBox() + .withProps({ + readOnly: false, + ariaLabel: loc.restorePoint, + value: '' + }).component(); + const databaseDetailsTitle = this.modelBuilder.text().withProps({ + value: loc.databaseDetails, + CSSStyles: { ...cssStyles.title } + }).component(); + const databaseDetailsTextLabel = this.modelBuilder.text().withProps({ + value: loc.databaseDetailsText, + }).component(); + this.databaseNameInputBox = this.modelBuilder.inputBox() + .withProps({ + readOnly: false, + ariaLabel: loc.databaseName, + value: '' + }).component(); + + this.instanceInputBox = this.modelBuilder.inputBox() + .withProps({ + enabled: false, + ariaLabel: loc.instance, + value: this.pitrSettings.instanceName + }).component(); + const info = this.modelBuilder.text().withProps({ + value: loc.restoreInfo, + CSSStyles: { ...cssStyles.text, 'margin-block-start': '0px', 'margin-block-end': '0px' } + }).component(); + + const link = this.modelBuilder.hyperlink().withProps({ + label: loc.learnMore, + url: 'https://docs.microsoft.com/azure/azure-arc/data/point-in-time-restore', + }).component(); + + const infoAndLink = this.modelBuilder.flexContainer().withLayout({ flexWrap: 'wrap' }).component(); + + infoAndLink.addItem(info, { CSSStyles: { 'margin-right': '5px' } }); + infoAndLink.addItem(link); + + let formModel = this.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: pitrTitle + }, + { + component: infoAndLink + }, + { + component: projectDetailsTitle, + }, + { + component: projectDetailsTextLabel, + }, + { + component: this.subscriptionInputBox, + title: loc.subscription, + + }, + { + component: this.resourceGroupInputBox, + title: loc.resourceGroup, + + }, + { + component: sourceDetailsTitle, + }, + { + component: sourceDetailsTextLabel, + }, + { + component: this.sourceDbInputBox, + title: loc.sourceDatabase, + + }, + { + component: this.earliestRestorePointInputBox, + title: loc.earliestPitrRestorePoint, + + }, + { + component: this.latestRestorePointInputBox, + title: loc.latestpitrRestorePoint, + + }, + { + component: this.restorePointInputBox, + title: loc.restorePoint, + required: true + }, + { + component: databaseDetailsTitle, + }, + { + component: databaseDetailsTextLabel, + }, + { + component: this.databaseNameInputBox, + title: loc.databaseName, + required: true + }, + { + component: this.instanceInputBox, + title: loc.instance, + + }, + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await view.initializeModel(formModel); + this.subscriptionInputBox.focus(); + this.resourceGroupInputBox.focus(); + this.sourceDbInputBox.focus(); + this.earliestRestorePointInputBox.focus(); + this.latestRestorePointInputBox.focus(); + this.restorePointInputBox.focus(); + this.databaseNameInputBox.focus(); + this.instanceInputBox.focus(); + this.initialized = true; + }); + + dialog.okButton.label = loc.restore; + dialog.cancelButton.label = loc.cancel; + dialog.registerCloseValidator(async () => await this.validate()); + dialog.okButton.onClick(() => { + this.pitrSettings.subscriptionId = this.subscriptionInputBox.value ?? ''; + this.pitrSettings.instanceName = this.instanceInputBox.value ?? ''; + this.pitrSettings.resourceGroupName = this.resourceGroupInputBox.value ?? ''; + this.pitrSettings.dbName = this.databaseNameInputBox.value ?? ''; + this.pitrSettings.earliestPitr = this.earliestRestorePointInputBox.value ?? ''; + this.pitrSettings.latestPitr = this.latestRestorePointInputBox.value ?? ''; + this.pitrSettings.restorePoint = this.restorePointInputBox.value ?? ''; + this._completionPromise.resolve(this.pitrSettings); + }); + azdata.window.openDialog(dialog); + return dialog; + } + + public async validate(): Promise { + if (!this.subscriptionInputBox.value || !this.resourceGroupInputBox.value + || !this.sourceDbInputBox.value || !this.latestRestorePointInputBox.value + || !this.restorePointInputBox.value || !this.databaseNameInputBox.value + || !this.instanceInputBox.value) { + return false; + } + else { + return true; + } + } + + private handleCancel(): void { + this._completionPromise.resolve(undefined); + } + + public waitForClose(): Promise { + return this._completionPromise.promise; + } + + public refreshPitrSettings(): void { + this.pitrSettings.instanceName = this._miaaModel?.config?.metadata.name || this.pitrSettings.instanceName; + this.pitrSettings.resourceGroupName = this._controllerModel?.controllerConfig?.spec.settings.azure.resourceGroup || this.pitrSettings.resourceGroupName; + this.pitrSettings.location = this._azurecoreApi.getRegionDisplayName(this._controllerModel?.controllerConfig?.spec.settings.azure.location) || this.pitrSettings.location; + this.pitrSettings.subscriptionId = this._controllerModel?.controllerConfig?.spec.settings.azure.subscription || this.pitrSettings.subscriptionId; + this.pitrSettings.dbName = this._database.name; + this.pitrSettings.restorePoint = this._database.lastBackup; + this.pitrSettings.earliestPitr = ''; + } +} diff --git a/extensions/azcli/src/api.ts b/extensions/azcli/src/api.ts index 3119f35df3..4fcae48652 100644 --- a/extensions/azcli/src/api.ts +++ b/extensions/azcli/src/api.ts @@ -132,6 +132,20 @@ export function getAzApi(localAzDiscovered: Promise, azTool validateAz(azToolService.localAz); return azToolService.localAz!.sql.miarc.edit(name, args, namespace, additionalEnvVars); } + }, + midbarc: { + restore: async (name: string, + args: { + destName?: string, + managedInstance?: string, + time?: string, + noWait?: boolean, + }, + namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars) => { + await localAzDiscovered; + validateAz(azToolService.localAz); + return azToolService.localAz!.sql.midbarc.restore(name, args, namespace, additionalEnvVars); + } } }, getPath: async () => { diff --git a/extensions/azcli/src/az.ts b/extensions/azcli/src/az.ts index fa6c8ee209..0a45c2ef6e 100644 --- a/extensions/azcli/src/az.ts +++ b/extensions/azcli/src/az.ts @@ -162,6 +162,7 @@ export class AzTool implements azExt.IAzApi { memoryLimit?: string, memoryRequest?: string, noWait?: boolean, + retentionDays?: string }, namespace: string, additionalEnvVars?: azExt.AdditionalEnvVars @@ -172,11 +173,33 @@ export class AzTool implements azExt.IAzApi { if (args.memoryLimit) { argsArray.push('--memory-limit', args.memoryLimit); } if (args.memoryRequest) { argsArray.push('--memory-request', args.memoryRequest); } if (args.noWait) { argsArray.push('--no-wait'); } + if (args.retentionDays) { argsArray.push('--retention-days', args.retentionDays); } + return this.executeCommand(argsArray, additionalEnvVars); + } + }, + midbarc: { + restore: ( + name: string, + args: { + destName?: string, + managedInstance?: string, + time?: string, + noWait?: boolean, + }, + namespace: string, + additionalEnvVars?: azExt.AdditionalEnvVars + ): Promise> => { + const argsArray = ['sql', 'midb-arc', 'restore', '--name', name, '--k8s-namespace', namespace, '--use-k8s']; + if (args.destName) { argsArray.push('--dest-name', args.destName); } + if (args.managedInstance) { argsArray.push('--managed-instance', args.managedInstance); } + if (args.time) { argsArray.push('--time', args.time); } + if (args.noWait) { argsArray.push('--no-wait'); } return this.executeCommand(argsArray, additionalEnvVars); } } }; + /** * Gets the output of running '--version' command on the az tool. * It also updates the cachedVersion property based on the return value from the tool. diff --git a/extensions/azcli/src/typings/az-ext.d.ts b/extensions/azcli/src/typings/az-ext.d.ts index 51cc8ef700..a4ff276c57 100644 --- a/extensions/azcli/src/typings/az-ext.d.ts +++ b/extensions/azcli/src/typings/az-ext.d.ts @@ -141,6 +141,9 @@ declare module 'az-ext' { uid: string // "cea737aa-3f82-4f6a-9bed-2b51c2c33dff" }, spec: { + backup?: { + retentionPeriodInDays: number, // 1 + } scheduling?: { default?: { resources?: { @@ -314,11 +317,25 @@ declare module 'az-ext' { edit( name: string, args: { - coresLimit?: string, - coresRequest?: string, - memoryLimit?: string, - memoryRequest?: string, - noWait?: boolean, + coresLimit?: string, //2 + coresRequest?: string, //1 + memoryLimit?: string, // 2Gi + memoryRequest?: string, //1Gi + noWait?: boolean, //true + retentionDays?: string, //5 + }, + namespace?: string, + additionalEnvVars?: AdditionalEnvVars + ): Promise> + }, + midbarc: { + restore( + name: string, + args: { + destName?: string, //testDb + managedInstance?: string, //sqlmi1 + time?: string, //2021-10-12T11:16:30.000Z + noWait?: boolean, //true }, namespace?: string, additionalEnvVars?: AdditionalEnvVars