diff --git a/extensions/sql-migration/images/cutover.svg b/extensions/sql-migration/images/cutover.svg index 1a361e628f..bc8fcd7649 100644 --- a/extensions/sql-migration/images/cutover.svg +++ b/extensions/sql-migration/images/cutover.svg @@ -1,3 +1,3 @@ - - + + diff --git a/extensions/sql-migration/package.json b/extensions/sql-migration/package.json index 6056a1a5bb..1aebca2769 100644 --- a/extensions/sql-migration/package.json +++ b/extensions/sql-migration/package.json @@ -2,7 +2,7 @@ "name": "sql-migration", "displayName": "%displayName%", "description": "%description%", - "version": "0.1.2", + "version": "0.1.3", "publisher": "Microsoft", "preview": true, "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/main/LICENSE.txt", diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index b613df7c44..4f1ecbdaec 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as azurecore from 'azurecore'; import { azureResource } from 'azureResource'; +import * as constants from '../constants/strings'; async function getAzureCoreAPI(): Promise { const api = (await vscode.extensions.getExtension(azurecore.extension.name)?.activate()) as azurecore.IExtension; @@ -159,6 +160,22 @@ export async function createSqlMigrationService(account: azdata.Account, subscri if (response.errors.length > 0) { throw new Error(response.errors.toString()); } + const asyncUrl = response.response.headers['azure-asyncoperation']; + const maxRetry = 5; + let i = 0; + for (i = 0; i < maxRetry; i++) { + const asyncResponse = await api.makeAzureRestRequest(account, subscription, asyncUrl.replace('https://management.azure.com/', ''), azurecore.HttpRequestMethod.GET, undefined, true); + const creationStatus = asyncResponse.response.data.status; + if (creationStatus === 'Succeeded') { + break; + } else if (creationStatus === 'Failed') { + throw new Error(asyncResponse.errors.toString()); + } + await new Promise(resolve => setTimeout(resolve, 3000)); //adding 3 sec delay before getting creation status + } + if (i === maxRetry) { + throw new Error(constants.DMS_PROVISIONING_FAILED); + } return response.response.data; } @@ -426,6 +443,8 @@ export interface MigrationStatusDetails { fileUploadBlockingErrors: string[]; currentRestoringFileName: string; lastRestoredFilename: string; + pendingLogBackupsCount: number; + invalidFiles: string[]; } export interface SqlConnectionInfo { @@ -462,6 +481,8 @@ export interface BackupSetInfo { isBackupRestored: boolean; backupSize: number; compressedBackupSize: number; + hasBackupChecksums: boolean; + familyCount: number; } export interface SourceLocation { @@ -477,6 +498,12 @@ export interface TargetLocation { export interface BackupFileInfo { fileName: string; status: 'Arrived' | 'Uploading' | 'Uploaded' | 'Restoring' | 'Restored' | 'Cancelled' | 'Ignored'; + totalSize: number; + dataRead: number; + dataWritten: number; + copyThroughput: number; + copyDuration: number; + familySequenceNumber: number; } export interface DatabaseMigrationFileShare { diff --git a/extensions/sql-migration/src/api/utils.ts b/extensions/sql-migration/src/api/utils.ts index 28b3a5b10e..a6585ba2b4 100644 --- a/extensions/sql-migration/src/api/utils.ts +++ b/extensions/sql-migration/src/api/utils.ts @@ -122,6 +122,25 @@ export function filterMigrations(databaseMigrations: MigrationContext[], statusF return filteredMigration; } +export function convertByteSizeToReadableUnit(size: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + for (let i = 1; i < units.length; i++) { + const higherUnit = size / 1024; + if (higherUnit < 0.1) { + return `${size.toFixed(2)} ${units[i - 1]}`; + } + size = higherUnit; + } + return size.toString(); +} + +export function convertIsoTimeToLocalTime(isoTime: string): Date { + let isoDate = new Date(isoTime); + return new Date(isoDate.getTime() + (isoDate.getTimezoneOffset() * 60000)); +} + +export type SupportedAutoRefreshIntervals = -1 | 15000 | 30000 | 60000 | 180000 | 300000; + export function selectDropDownIndex(dropDown: DropDownComponent, index: number): void { if (index >= 0 && dropDown.values && index <= dropDown.values.length - 1) { const value = dropDown.values[index]; diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 0bb430a9ef..b6eda86617 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -5,6 +5,7 @@ import { AzureAccount } from 'azurecore'; import * as nls from 'vscode-nls'; +import { SupportedAutoRefreshIntervals } from '../api/utils'; import { MigrationSourceAuthenticationType } from '../models/stateMachine'; const localize = nls.loadMessageBundle(); @@ -182,7 +183,7 @@ export const SERVICE_CONTAINER_DESCRIPTION2 = localize('sql.migration.service.co export const SERVICE_STEP1 = localize('sql.migration.ir.setup.step1', "Step 1: {0}"); export const SERVICE_STEP1_LINK = localize('sql.migration.option', "Download and install integration runtime"); export const SERVICE_STEP2 = localize('sql.migration.ir.setup.step2', "Step 2: Use this key to register your integration runtime"); -export const SERVICE_STEP3 = localize('sql.migration.ir.setup.step3', "Step 3: Check connection between Azure Database Migration Service and Integration Runtime"); +export const SERVICE_STEP3 = localize('sql.migration.ir.setup.step3', "Step 3: Click on 'Test connection' button to check the connection between Azure Database Migration Service and Integration Runtime"); export const SERVICE_CONNECTION_STATUS = localize('sql.migration.connection.status', "Connection Status"); export const SERVICE_KEY1_LABEL = localize('sql.migration.key1.label', "Key 1"); export const SERVICE_KEY2_LABEL = localize('sql.migration.key2.label', "Key 2"); @@ -209,7 +210,9 @@ export const MANAGED_INSTANCE = localize('sql.migration.managed.instance', "Azur export const NO_MANAGED_INSTANCE_FOUND = localize('sql.migration.no.managedInstance.found', "No managed instance found"); export const NO_VIRTUAL_MACHINE_FOUND = localize('sql.migration.no.virtualMachine.found', "No virtual machine found"); export const TARGET_SELECTION_PAGE_TITLE = localize('sql.migration.target.page.title', "Choose the target Azure SQL"); - +export const TEST_CONNECTION = localize('sql.migration.test.connection', "Test connection"); +export const DATA_MIGRATION_SERVICE_CREATED_SUCCESSFULLY = localize('sql.migration.database.migration.service.created.successfully', "Database migration service has been created successfully"); +export const DMS_PROVISIONING_FAILED = localize('sql.migration.dms.provision.failed', "Database migration service has failed to provision. Please try again after some time."); // common strings export const LEARN_MORE = localize('sql.migration.learn.more', "Learn more"); export const SUBSCRIPTION = localize('sql.migration.subscription', "Subscription"); @@ -230,6 +233,9 @@ export const USER_ACCOUNT = localize('sql.migration.path.user.account', "User Ac export const VIEW_ALL = localize('sql.migration.view.all', "View All"); export const TARGET = localize('sql.migration.target', "Target"); export const AZURE_SQL = localize('sql.migration.azure.sql', "Azure SQL"); +export const CLOSE = localize('sql.migration.close', "Close"); +export const DATA_UPLOADED = localize('sql.migraiton.data.uploaded.size', "Data Uploaded/Size"); +export const COPY_THROUGHPUT = localize('sql.migration.copy.throughput', "Copy Throughput (MBPS)"); //Summary Page export const SUMMARY_PAGE_TITLE = localize('sql.migration.summary.page.title', "Summary"); @@ -331,15 +337,16 @@ export const YES = localize('sql.migration.yes', "Yes"); export const NO = localize('sql.migration.no', "No"); //Migration confirm cutover dialog +export const COMPLETING_CUTOVER_WARNING = localize('sql.migration.completing.cutover.warning', "Completing cutover without restoring all the backup(s) may result in loss of data."); export const BUSINESS_CRITICAL_INFO = localize('sql.migration.bc.info', "Managed Instance migration cutover for Business Critical service tier can take significantly longer than General Purpose as three secondary replicas have to be seeded for Always On High Availability group. This operation duration depends on the size of data. Seeding speed in 90% of cases is 220 GB/hour or higher."); export const CUTOVER_HELP_MAIN = localize('sql.migration.cutover.help.main', "When you are ready to do the migration cutover, perform the following steps to complete the database migration. Please note that the database is ready for cutover only after a full backup has been restored on the target Azure SQL Database Managed Instance."); export const CUTOVER_HELP_STEP1 = localize('sql.migration.cutover.step.1', "1. Stop all the incoming transactions coming to the source database."); -export const CUTOVER_HELP_STEP2 = localize('sql.migration.cutover.step.2', "2. Take the final transaction log backup and provide backup file in the SMB network share."); -export const CUTOVER_HELP_STEP3 = localize('sql.migration.cutover.step.3', "3. Make sure all the pending log backups are restored on the target. At that point, “Pending log backups” counter shows zero and then perform the cutover. Performing cutover operation without applying all the transaction log backup files may result in loss of data."); +export const CUTOVER_HELP_STEP2 = localize('sql.migration.cutover.step.2', "2. Take final transaction log backup and provide it in the network share location."); +export const CUTOVER_HELP_STEP3 = localize('sql.migration.cutover.step.3', "3. Make sure all the log backups are restored on target database. The \"Log backups(s) pending restore\" should be zero."); export function PENDING_BACKUPS(count: number): string { return localize('sql.migartion.cutover.pending.backup', "Pending log backups: {0}", count); } -export const CONFIRM_CUTOVER_CHECKBOX = localize('sql.migration.confirm.checkbox.message', "Confirm all pending log backups are restored"); +export const CONFIRM_CUTOVER_CHECKBOX = localize('sql.migration.confirm.checkbox.message', "I confirm there are no additional log backup(s) to provide and want to complete cutover."); export function CUTOVER_IN_PROGRESS(dbName: string): string { return localize('sql.migration.cutover.in.progress', "Cutover in progress for database '{0}'", dbName); } @@ -444,3 +451,28 @@ export function WARNINGS_COUNT(totalCount: number): string { export const AUTHENTICATION_TYPE = localize('sql.migration.authentication.type', "Authentication Type"); export const SQL_LOGIN = localize('sql.migration.sql.login', "SQL Login"); export const WINDOWS_AUTHENTICATION = localize('sql.migration.windows.auth', "Windows Authentication"); + +//AutoRefresh +export function AUTO_REFRESH_BUTTON_TEXT(interval: SupportedAutoRefreshIntervals): string { + switch (interval) { + case -1: + return localize('sql.migration.auto.refresh.off', 'Auto Refresh: Off'); + case 15000: + return localize('sql.migration.auto.refresh.15.seconds', 'Auto refresh: 15 seconds'); + case 30000: + return localize('sql.migration.auto.refresh.30.seconds', 'Auto refresh: 30 seconds'); + case 60000: + return localize('sql.migration.auto.refresh.1.min', 'Auto refresh: 1 minute'); + case 180000: + return localize('sql.migration.auto.refresh.3.min', 'Auto refresh: 3 minutes'); + case 300000: + return localize('sql.migration.auto.refresh.5.min', 'Auto refresh: 5 minutes'); + } +} + +export const SELECT_THE_REFRESH_INTERVAL = localize('sql.migration.select.the.refresh.interval', "Select the refresh interval"); +export const OFF = localize('sql.migration.off', "Off"); +export const EVERY_30_SECOND = localize('sql.migration.every.30.second', "Every 30 seconds"); +export const EVERY_1_MINUTE = localize('sql.migration.every.1.minute', "Every 1 minute"); +export const EVERY_3_MINUTES = localize('sql.migration.every.3.minutes', "Every 3 minutes"); +export const EVERY_5_MINUTES = localize('sql.migration.every.5.minutes', "Every 5 minutes"); diff --git a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts index 7d5af19b5a..15563613f9 100644 --- a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts +++ b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts @@ -10,7 +10,7 @@ import * as loc from '../constants/strings'; import { IconPath, IconPathHelper } from '../constants/iconPathHelper'; import { MigrationStatusDialog } from '../dialog/migrationStatus/migrationStatusDialog'; import { AdsMigrationStatus } from '../dialog/migrationStatus/migrationStatusDialogModel'; -import { filterMigrations } from '../api/utils'; +import { filterMigrations, SupportedAutoRefreshIntervals } from '../api/utils'; interface IActionMetadata { title?: string, @@ -21,6 +21,7 @@ interface IActionMetadata { } const maxWidth = 800; +const refreshFrequency: SupportedAutoRefreshIntervals = 180000; interface StatusCard { container: azdata.DivContainer; @@ -46,10 +47,9 @@ export class DashboardWidget { private _migrationStatusMap: Map = new Map(); private _viewAllMigrationsButton!: azdata.ButtonComponent; + private _autoRefreshHandle!: NodeJS.Timeout; + constructor() { - vscode.commands.registerCommand('sqlmigration.refreshMigrationTiles', () => { - this.refreshMigrations(); - }); } private async getCurrentMigrations(): Promise { @@ -95,7 +95,9 @@ export class DashboardWidget { } }); await view.initializeModel(container); - + this._view.onClosed((e) => { + clearInterval(this._autoRefreshHandle); + }); this.refreshMigrations(); }); } @@ -107,11 +109,19 @@ export class DashboardWidget { }).component(); const titleComponent = view.modelBuilder.text().withProps({ value: loc.DASHBOARD_TITLE, + width: '750px', CSSStyles: { 'font-size': '36px', 'margin-bottom': '5px', } }).component(); + + this.setAutoRefresh(refreshFrequency); + + const container = view.modelBuilder.flexContainer().withItems([ + titleComponent, + ]).component(); + const descComponent = view.modelBuilder.text().withProps({ value: loc.DASHBOARD_DESCRIPTION, CSSStyles: { @@ -119,7 +129,7 @@ export class DashboardWidget { 'margin-top': '10px', } }).component(); - header.addItems([titleComponent, descComponent], { + header.addItems([container, descComponent], { CSSStyles: { 'width': `${maxWidth}px`, 'padding-left': '20px' @@ -231,6 +241,14 @@ export class DashboardWidget { return view.modelBuilder.divContainer().withItems([buttonContainer]).component(); } + private setAutoRefresh(interval: SupportedAutoRefreshIntervals): void { + let classVariable = this; + clearInterval(this._autoRefreshHandle); + if (interval !== -1) { + this._autoRefreshHandle = setInterval(function () { classVariable.refreshMigrations(); }, interval); + } + } + private async refreshMigrations(): Promise { this._viewAllMigrationsButton.enabled = false; this._migrationStatusCardLoadingContainer.loading = true; diff --git a/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts b/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts index 343b1a3ef0..805cb1f8b5 100644 --- a/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts +++ b/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts @@ -58,7 +58,7 @@ export class AssessmentResultsDialog { public async openDialog(dialogName?: string) { if (!this._isOpen) { this._isOpen = true; - this.dialog = azdata.window.createModelViewDialog(this.title, this.title, '90%'); + this.dialog = azdata.window.createModelViewDialog(this.title, this.title, 'wide'); this.dialog.okButton.label = AssessmentResultsDialog.OkButtonText; this.dialog.okButton.onClick(async () => await this.execute()); diff --git a/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts b/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts index 494e87824f..ad3320a1bc 100644 --- a/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts +++ b/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts @@ -5,17 +5,18 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; -import { createSqlMigrationService, getSqlMigrationService, getResourceGroups, getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData, SqlMigrationService } from '../../api/azure'; -import { MigrationStateModel } from '../../models/stateMachine'; +import { createSqlMigrationService, getSqlMigrationService, getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData, SqlMigrationService } from '../../api/azure'; +import { MigrationStateModel, NetworkContainerType } from '../../models/stateMachine'; import * as constants from '../../constants/strings'; import * as os from 'os'; import { azureResource } from 'azureResource'; -import { IntergrationRuntimePage } from '../../wizard/integrationRuntimePage'; import { IconPathHelper } from '../../constants/iconPathHelper'; -import { selectDropDownIndex } from '../../api/utils'; +import * as EventEmitter from 'events'; export class CreateSqlMigrationServiceDialog { + private _model!: MigrationStateModel; + private migrationServiceSubscription!: azdata.TextComponent; private migrationServiceResourceGroupDropdown!: azdata.DropDownComponent; private migrationServiceLocation!: azdata.InputBoxComponent; @@ -23,6 +24,7 @@ export class CreateSqlMigrationServiceDialog { private _formSubmitButton!: azdata.ButtonComponent; private _statusLoadingComponent!: azdata.LoadingComponent; + private _refreshLoadingComponent!: azdata.LoadingComponent; private migrationServiceAuthKeyTable!: azdata.DeclarativeTableComponent; private _connectionStatus!: azdata.InfoBoxComponent; private _copyKey1Button!: azdata.ButtonComponent; @@ -30,18 +32,24 @@ export class CreateSqlMigrationServiceDialog { private _refreshKey1Button!: azdata.ButtonComponent; private _refreshKey2Button!: azdata.ButtonComponent; private _setupContainer!: azdata.FlexContainer; + private _resourceGroupPreset!: string; private _dialogObject!: azdata.window.Dialog; private _view!: azdata.ModelView; - private createdMigrationService!: SqlMigrationService; - private createdMigrationServiceNodeNames!: string[]; + private _createdMigrationService!: SqlMigrationService; + private _selectedResourceGroup!: string; + private _testConnectionButton!: azdata.window.Button; - constructor(private migrationStateModel: MigrationStateModel, private irPage: IntergrationRuntimePage) { + private _doneButtonEvent: EventEmitter = new EventEmitter(); + private _isBlobContainerUsed: boolean = false; + + private irNodes: string[] = []; + + public async createNewDms(migrationStateModel: MigrationStateModel, resourceGroupPreset: string): Promise { + this._model = migrationStateModel; + this._resourceGroupPreset = resourceGroupPreset; this._dialogObject = azdata.window.createModelViewDialog(constants.CREATE_MIGRATION_SERVICE_TITLE, 'MigrationServiceDialog', 'medium'); - } - - initialize() { let tab = azdata.window.createTab(''); this._dialogObject.registerCloseValidator(async () => { return true; @@ -63,9 +71,9 @@ export class CreateSqlMigrationServiceDialog { this.setFormEnabledState(false); - const subscription = this.migrationStateModel._targetSubscription; - const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name; - const location = this.migrationStateModel._targetServerInstance.location; + const subscription = this._model._targetSubscription; + const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).displayName; + const location = this._model._targetServerInstance.location; const serviceName = this.migrationServiceNameText.value; const formValidationErrors = this.validateCreateServiceForm(subscription, resourceGroup, location, serviceName); @@ -78,9 +86,10 @@ export class CreateSqlMigrationServiceDialog { } try { - this.createdMigrationService = await createSqlMigrationService(this.migrationStateModel._azureAccount, subscription, resourceGroup, location, serviceName!); - if (this.createdMigrationService.error) { - this.setDialogMessage(`${this.createdMigrationService.error.code} : ${this.createdMigrationService.error.message}`); + this._selectedResourceGroup = resourceGroup; + this._createdMigrationService = await createSqlMigrationService(this._model._azureAccount, subscription, resourceGroup, location, serviceName!); + if (this._createdMigrationService.error) { + this.setDialogMessage(`${this._createdMigrationService.error.code} : ${this._createdMigrationService.error.message}`); this._statusLoadingComponent.loading = false; this.setFormEnabledState(true); return; @@ -88,10 +97,22 @@ export class CreateSqlMigrationServiceDialog { this._dialogObject.message = { text: '' }; - await this.refreshAuthTable(); - await this.refreshStatus(); - this._setupContainer.display = 'inline'; - this._statusLoadingComponent.loading = false; + + if (this._isBlobContainerUsed) { + this._dialogObject.okButton.enabled = true; + this._statusLoadingComponent.loading = false; + this._setupContainer.display = 'none'; + this._dialogObject.message = { + text: constants.DATA_MIGRATION_SERVICE_CREATED_SUCCESSFULLY, + level: azdata.window.MessageLevel.Information + }; + } else { + await this.refreshStatus(); + await this.refreshAuthTable(); + this._setupContainer.display = 'inline'; + this._testConnectionButton.hidden = false; + this._statusLoadingComponent.loading = false; + } } catch (e) { console.log(e); this.setDialogMessage(e.message); @@ -135,13 +156,45 @@ export class CreateSqlMigrationServiceDialog { }); }); + this._testConnectionButton = azdata.window.createButton(constants.TEST_CONNECTION); + this._testConnectionButton.hidden = true; + this._testConnectionButton.onClick(async (e) => { + this._refreshLoadingComponent.loading = true; + this._connectionStatus.updateCssStyles({ + 'display': 'none' + }); + try { + await this.refreshStatus(); + } catch (e) { + vscode.window.showErrorMessage(e); + } + this._connectionStatus.updateCssStyles({ + 'display': 'inline' + }); + this._refreshLoadingComponent.loading = false; + }); + this._dialogObject.customButtons = [this._testConnectionButton]; + this._dialogObject.content = [tab]; this._dialogObject.okButton.enabled = false; azdata.window.openDialog(this._dialogObject); this._dialogObject.cancelButton.onClick((e) => { }); this._dialogObject.okButton.onClick((e) => { - this.irPage.populateMigrationService(this.createdMigrationService, this.createdMigrationServiceNodeNames, (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name); + this._doneButtonEvent.emit('done', this._createdMigrationService, this._selectedResourceGroup); + }); + + this._isBlobContainerUsed = this._model._databaseBackup.networkContainerType === NetworkContainerType.BLOB_CONTAINER; + + return new Promise((resolve) => { + this._doneButtonEvent.once('done', (createdDms: SqlMigrationService, selectedResourceGroup: string) => { + azdata.window.closeDialog(this._dialogObject); + resolve( + { + service: createdDms, + resourceGroup: selectedResourceGroup + }); + }); }); } @@ -201,7 +254,7 @@ export class CreateSqlMigrationServiceDialog { this.migrationServiceLocation = this._view.modelBuilder.inputBox().withProps({ required: true, enabled: false, - value: await this.migrationStateModel.getLocationDisplayName(this.migrationStateModel._targetServerInstance.location) + value: await this._model.getLocationDisplayName(this._model._targetServerInstance.location) }).component(); const targetlabel = this._view.modelBuilder.text().withProps({ @@ -254,33 +307,19 @@ export class CreateSqlMigrationServiceDialog { private async populateSubscriptions(): Promise { this.migrationServiceResourceGroupDropdown.loading = true; - this.migrationServiceSubscription.value = this.migrationStateModel._targetSubscription.name; + this.migrationServiceSubscription.value = this._model._targetSubscription.name; await this.populateResourceGroups(); } private async populateResourceGroups(): Promise { this.migrationServiceResourceGroupDropdown.loading = true; - let subscription = this.migrationStateModel._targetSubscription; - const resourceGroups = await getResourceGroups(this.migrationStateModel._azureAccount, subscription); - let resourceGroupDropdownValues: azdata.CategoryValue[] = []; - if (resourceGroups && resourceGroups.length > 0) { - resourceGroups.forEach((resourceGroup) => { - resourceGroupDropdownValues.push({ - name: resourceGroup.name, - displayName: resourceGroup.name - }); - }); - } else { - resourceGroupDropdownValues = [ - { - displayName: constants.RESOURCE_GROUP_NOT_FOUND, - name: '' - } - ]; + try { + this.migrationServiceResourceGroupDropdown.values = await this._model.getAzureResourceGroupDropdownValues(this._model._targetSubscription); + const selectedResourceGroupValue = this.migrationServiceResourceGroupDropdown.values.find(v => v.displayName.toLowerCase() === this._resourceGroupPreset.toLowerCase()); + this.migrationServiceResourceGroupDropdown.value = (selectedResourceGroupValue) ? selectedResourceGroupValue : this.migrationServiceResourceGroupDropdown.values[0]; + } finally { + this.migrationServiceResourceGroupDropdown.loading = false; } - this.migrationServiceResourceGroupDropdown.values = resourceGroupDropdownValues; - selectDropDownIndex(this.migrationServiceResourceGroupDropdown, 0); - this.migrationServiceResourceGroupDropdown.loading = false; } private createServiceStatus(): azdata.FlexContainer { @@ -327,9 +366,8 @@ export class CreateSqlMigrationServiceDialog { } }).component(); - const irSetupStep3Text = this._view.modelBuilder.hyperlink().withProps({ - label: constants.SERVICE_STEP3, - url: '', + const irSetupStep3Text = this._view.modelBuilder.text().withProps({ + value: constants.SERVICE_STEP3, CSSStyles: { 'margin-top': '10px', 'margin-bottom': '10px', @@ -337,23 +375,6 @@ export class CreateSqlMigrationServiceDialog { } }).component(); - irSetupStep3Text.onDidClick(async (e) => { - refreshLoadingIndicator.loading = true; - this._connectionStatus.updateCssStyles({ - 'display': 'none' - }); - try { - await this.refreshStatus(); - } catch (e) { - console.log(e); - } - this._connectionStatus.updateCssStyles({ - 'display': 'inline' - }); - refreshLoadingIndicator.loading = false; - }); - - this._connectionStatus = this._view.modelBuilder.infoBox().withProps({ text: '', style: 'error', @@ -366,7 +387,7 @@ export class CreateSqlMigrationServiceDialog { 'width': '350px' }; - const refreshLoadingIndicator = this._view.modelBuilder.loadingComponent().withProps({ + this._refreshLoadingComponent = this._view.modelBuilder.loadingComponent().withProps({ loading: false, CSSStyles: { 'font-size': '13px' @@ -428,7 +449,7 @@ export class CreateSqlMigrationServiceDialog { this.migrationServiceAuthKeyTable, irSetupStep3Text, this._connectionStatus, - refreshLoadingIndicator + this._refreshLoadingComponent ], { CSSStyles: { 'margin-bottom': '5px' @@ -439,16 +460,28 @@ export class CreateSqlMigrationServiceDialog { }).component(); this._setupContainer.display = 'none'; + this._testConnectionButton.hidden = true; return this._setupContainer; } private async refreshStatus(): Promise { - const subscription = this.migrationStateModel._targetSubscription; - const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name; - const location = this.migrationStateModel._targetServerInstance.location; - const migrationServiceStatus = await getSqlMigrationService(this.migrationStateModel._azureAccount, subscription, resourceGroup, location, this.createdMigrationService!.name); - const migrationServiceMonitoringStatus = await getSqlMigrationServiceMonitoringData(this.migrationStateModel._azureAccount, subscription, resourceGroup, location, this.createdMigrationService!.name); - this.createdMigrationServiceNodeNames = migrationServiceMonitoringStatus.nodes.map((node) => { + const subscription = this._model._targetSubscription; + const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).displayName; + const location = this._model._targetServerInstance.location; + + const maxRetries = 5; + let migrationServiceStatus!: SqlMigrationService; + for (let i = 0; i < maxRetries; i++) { + try { + migrationServiceStatus = await getSqlMigrationService(this._model._azureAccount, subscription, resourceGroup, location, this._createdMigrationService.name); + break; + } catch (e) { + console.log(e); + } + await new Promise(r => setTimeout(r, 5000)); + } + const migrationServiceMonitoringStatus = await getSqlMigrationServiceMonitoringData(this._model._azureAccount, subscription, resourceGroup, location, this._createdMigrationService!.name); + this.irNodes = migrationServiceMonitoringStatus.nodes.map((node) => { return node.nodeName; }); if (migrationServiceStatus) { @@ -456,7 +489,7 @@ export class CreateSqlMigrationServiceDialog { if (state === 'Online') { this._connectionStatus.updateProperties({ - text: constants.SERVICE_READY(this.createdMigrationService!.name, this.createdMigrationServiceNodeNames.join(', ')), + text: constants.SERVICE_READY(this._createdMigrationService!.name, this.irNodes.join(', ')), style: 'success', CSSStyles: { 'font-size': '13px' @@ -464,9 +497,9 @@ export class CreateSqlMigrationServiceDialog { }); this._dialogObject.okButton.enabled = true; } else { - this._connectionStatus.text = constants.SERVICE_NOT_READY(this.createdMigrationService!.name); + this._connectionStatus.text = constants.SERVICE_NOT_READY(this._createdMigrationService!.name); this._connectionStatus.updateProperties({ - text: constants.SERVICE_NOT_READY(this.createdMigrationService!.name), + text: constants.SERVICE_NOT_READY(this._createdMigrationService!.name), style: 'warning', CSSStyles: { 'font-size': '13px' @@ -478,10 +511,10 @@ export class CreateSqlMigrationServiceDialog { } private async refreshAuthTable(): Promise { - const subscription = this.migrationStateModel._targetSubscription; - const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).name; - const location = this.migrationStateModel._targetServerInstance.location; - const keys = await getSqlMigrationServiceAuthKeys(this.migrationStateModel._azureAccount, subscription, resourceGroup, location, this.createdMigrationService!.name); + const subscription = this._model._targetSubscription; + const resourceGroup = (this.migrationServiceResourceGroupDropdown.value as azdata.CategoryValue).displayName; + const location = this._model._targetServerInstance.location; + const keys = await getSqlMigrationServiceAuthKeys(this._model._azureAccount, subscription, resourceGroup, location, this._createdMigrationService!.name); this._copyKey1Button = this._view.modelBuilder.button().withProps({ iconPath: IconPathHelper.copy @@ -557,3 +590,8 @@ export class CreateSqlMigrationServiceDialog { this.migrationServiceNameText.enabled = enable; } } + +export interface CreateSqlMigrationServiceDialogResult { + service: SqlMigrationService, + resourceGroup: string +} diff --git a/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts b/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts index 88ab74c262..77528412e5 100644 --- a/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts @@ -41,21 +41,6 @@ export class ConfirmCutoverDialog { const separator = this._view.modelBuilder.separator().withProps({ width: '800px' }).component(); - let infoDisplay = 'none'; - if (this.migrationCutoverModel._migration.targetManagedInstance.id.toLocaleLowerCase().includes('managedinstances') - && (this.migrationCutoverModel._migration.targetManagedInstance)?.sku?.tier === 'BusinessCritical') { - infoDisplay = 'inline'; - } - - const businessCriticalinfoBox = this._view.modelBuilder.infoBox().withProps({ - text: constants.BUSINESS_CRITICAL_INFO, - style: 'information', - CSSStyles: { - 'font-size': '13px', - 'display': infoDisplay - } - }).component(); - const helpMainText = this._view.modelBuilder.text().withProps({ value: constants.CUTOVER_HELP_MAIN, CSSStyles: { @@ -73,7 +58,7 @@ export class ConfirmCutoverDialog { }).component(); - const pendingBackupCount = this.migrationCutoverModel.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.filter(f => f.listOfBackupFiles[0].status !== 'Restored' && f.listOfBackupFiles[0].status !== 'Ignored').length ?? 0; + const pendingBackupCount = this.migrationCutoverModel.migrationStatus.properties.migrationStatusDetails?.pendingLogBackupsCount ?? 0; const pendingText = this._view.modelBuilder.text().withProps({ CSSStyles: { 'font-size': '13px', @@ -93,6 +78,29 @@ export class ConfirmCutoverDialog { this._dialogObject.okButton.enabled = e; }); + const cutoverWarning = this._view.modelBuilder.infoBox().withProps({ + text: constants.COMPLETING_CUTOVER_WARNING, + style: 'warning', + CSSStyles: { + 'font-size': '13px', + } + }).component(); + + + let infoDisplay = 'none'; + if (this.migrationCutoverModel._migration.targetManagedInstance.id.toLocaleLowerCase().includes('managedinstances') + && (this.migrationCutoverModel._migration.targetManagedInstance)?.sku?.tier === 'BusinessCritical') { + infoDisplay = 'inline'; + } + + const businessCriticalinfoBox = this._view.modelBuilder.infoBox().withProps({ + text: constants.BUSINESS_CRITICAL_INFO, + style: 'information', + CSSStyles: { + 'font-size': '13px', + 'display': infoDisplay + } + }).component(); const container = this._view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' @@ -100,11 +108,12 @@ export class ConfirmCutoverDialog { completeCutoverText, sourceDatabaseText, separator, - businessCriticalinfoBox, helpMainText, helpStepsText, pendingText, - confirmCheckbox + confirmCheckbox, + cutoverWarning, + businessCriticalinfoBox ]).component(); diff --git a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts index 391385251d..2b7c7b5e7a 100644 --- a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts @@ -8,11 +8,13 @@ import { IconPathHelper } from '../../constants/iconPathHelper'; import { MigrationContext } from '../../models/migrationLocalStorage'; import { MigrationCutoverDialogModel, MigrationStatus } from './migrationCutoverDialogModel'; import * as loc from '../../constants/strings'; -import { getSqlServerName } from '../../api/utils'; +import { convertByteSizeToReadableUnit, convertIsoTimeToLocalTime, getSqlServerName, SupportedAutoRefreshIntervals } from '../../api/utils'; import { EOL } from 'os'; import * as vscode from 'vscode'; import { ConfirmCutoverDialog } from './confirmCutoverDialog'; +const refreshFrequency: SupportedAutoRefreshIntervals = 30000; + export class MigrationCutoverDialog { private _dialogObject!: azdata.window.Dialog; private _view!: azdata.ModelView; @@ -41,10 +43,13 @@ export class MigrationCutoverDialog { private _fileCount!: azdata.TextComponent; private fileTable!: azdata.TableComponent; + private _autoRefreshHandle!: any; + readonly _infoFieldWidth: string = '250px'; + constructor(migration: MigrationContext) { this._model = new MigrationCutoverDialogModel(migration); - this._dialogObject = azdata.window.createModelViewDialog('', 'MigrationCutoverDialog', 1000); + this._dialogObject = azdata.window.createModelViewDialog('', 'MigrationCutoverDialog', 'wide'); } async initialize(): Promise { @@ -65,17 +70,17 @@ export class MigrationCutoverDialog { flexServer.addItem(sourceDatabase.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexServer.addItem(sourceDetails.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexServer.addItem(sourceVersion.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -93,17 +98,17 @@ export class MigrationCutoverDialog { flexTarget.addItem(targetDatabase.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexTarget.addItem(targetServer.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexTarget.addItem(targetVersion.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -122,17 +127,17 @@ export class MigrationCutoverDialog { flexStatus.addItem(migrationStatus.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexStatus.addItem(fullBackupFileOn.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexStatus.addItem(backupLocation.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -150,30 +155,28 @@ export class MigrationCutoverDialog { }).component(); flexFile.addItem(lastSSN.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexFile.addItem(lastAppliedBackup.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); flexFile.addItem(lastAppliedBackupOn.flexContainer, { CSSStyles: { - 'width': '200px' + 'width': this._infoFieldWidth } }); const flexInfo = view.modelBuilder.flexContainer().withProps({ - CSSStyles: { - 'width': '800px', - } + width: 1000 }).component(); flexInfo.addItem(flexServer, { flex: '0', CSSStyles: { 'flex': '0', - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -181,7 +184,7 @@ export class MigrationCutoverDialog { flex: '0', CSSStyles: { 'flex': '0', - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -189,7 +192,7 @@ export class MigrationCutoverDialog { flex: '0', CSSStyles: { 'flex': '0', - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -197,7 +200,7 @@ export class MigrationCutoverDialog { flex: '0', CSSStyles: { 'flex': '0', - 'width': '200px' + 'width': this._infoFieldWidth } }); @@ -213,7 +216,7 @@ export class MigrationCutoverDialog { columns: [ { value: loc.ACTIVE_BACKUP_FILES, - width: 280, + width: 230, type: azdata.ColumnType.text, }, { @@ -226,23 +229,36 @@ export class MigrationCutoverDialog { width: 60, type: azdata.ColumnType.text }, + { + value: loc.DATA_UPLOADED, + width: 120, + type: azdata.ColumnType.text + }, + { + value: loc.COPY_THROUGHPUT, + width: 150, + type: azdata.ColumnType.text + }, { value: loc.BACKUP_START_TIME, width: 130, type: azdata.ColumnType.text - }, { + }, + { value: loc.FIRST_LSN, width: 120, type: azdata.ColumnType.text - }, { + }, + { value: loc.LAST_LSN, width: 120, type: azdata.ColumnType.text } ], data: [], - width: '800px', + width: '1100px', height: '300px', + fontSize: '12px' }).component(); const formBuilder = view.modelBuilder.formContainer().withFormItems( @@ -251,13 +267,13 @@ export class MigrationCutoverDialog { component: this.migrationContainerHeader() }, { - component: this._view.modelBuilder.separator().withProps({ width: '800px' }).component() + component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() }, { component: flexInfo }, { - component: this._view.modelBuilder.separator().withProps({ width: '800px' }).component() + component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() }, { component: this._fileCount @@ -271,11 +287,21 @@ export class MigrationCutoverDialog { } ); const form = formBuilder.withLayout({ width: '100%' }).component(); + this._view.onClosed(e => { + clearInterval(this._autoRefreshHandle); + }); return view.initializeModel(form).then((value) => { this.refreshStatus(); }); }); this._dialogObject.content = [tab]; + + this._dialogObject.cancelButton.hidden = true; + this._dialogObject.okButton.label = loc.CLOSE; + + this._dialogObject.okButton.onClick(e => { + clearInterval(this._autoRefreshHandle); + }); azdata.window.openDialog(this._dialogObject); } @@ -295,6 +321,7 @@ export class MigrationCutoverDialog { 'font-weight': 'bold', 'margin': '0px' }, + width: 950, value: this._model._migration.migrationContext.properties.sourceDatabaseName }).component(); @@ -303,6 +330,7 @@ export class MigrationCutoverDialog { 'font-size': '10px', 'margin': '5px 0px' }, + width: 950, value: loc.DATABASE }).component(); @@ -311,31 +339,43 @@ export class MigrationCutoverDialog { databaseSubTitle ]).withLayout({ 'flexFlow': 'column' + }).withProps({ + width: 950 }).component(); + this.setAutoRefresh(refreshFrequency); - const titleLogoContainer = this._view.modelBuilder.flexContainer().component(); + const titleLogoContainer = this._view.modelBuilder.flexContainer().withProps({ + width: 1000 + }).component(); titleLogoContainer.addItem(sqlDatbaseLogo, { flex: '0' }); titleLogoContainer.addItem(titleContainer, { + flex: '0', CSSStyles: { - 'margin-left': '5px' + 'margin-left': '5px', + 'width': '930px' } }); const headerActions = this._view.modelBuilder.flexContainer().withLayout({ + }).withProps({ + width: 1000 }).component(); this._cutoverButton = this._view.modelBuilder.button().withProps({ iconPath: IconPathHelper.cutover, - iconHeight: '14px', - iconWidth: '12px', + iconHeight: '16px', + iconWidth: '16px', label: loc.COMPLETE_CUTOVER, height: '20px', - width: '130px', - enabled: false + width: '150px', + enabled: false, + CSSStyles: { + 'font-size': '13px' + } }).component(); this._cutoverButton.onDidClick(async (e) => { @@ -355,7 +395,10 @@ export class MigrationCutoverDialog { iconWidth: '16px', label: loc.CANCEL_MIGRATION, height: '20px', - width: '120px' + width: '150px', + CSSStyles: { + 'font-size': '13px' + } }).component(); this._cancelButton.onDidClick((e) => { @@ -378,7 +421,10 @@ export class MigrationCutoverDialog { iconWidth: '16px', label: 'Refresh', height: '20px', - width: '65px' + width: '100px', + CSSStyles: { + 'font-size': '13px' + } }).component(); this._refreshButton.onDidClick((e) => { @@ -395,7 +441,10 @@ export class MigrationCutoverDialog { iconWidth: '16px', label: loc.COPY_MIGRATION_DETAILS, height: '20px', - width: '150px' + width: '200px', + CSSStyles: { + 'font-size': '13px' + } }).component(); this._copyDatabaseMigrationDetails.onDidClick(async (e) => { @@ -435,6 +484,10 @@ export class MigrationCutoverDialog { titleLogoContainer ]).withLayout({ flexFlow: 'column' + }).withProps({ + CSSStyles: { + width: 1000 + } }).component(); header.addItem(headerActions, { @@ -446,6 +499,14 @@ export class MigrationCutoverDialog { return header; } + private setAutoRefresh(interval: SupportedAutoRefreshIntervals): void { + const classVariable = this; + clearInterval(this._autoRefreshHandle); + if (interval !== -1) { + this._autoRefreshHandle = setInterval(function () { classVariable.refreshStatus(); }, interval); + } + } + private async refreshStatus(): Promise { try { @@ -478,7 +539,6 @@ export class MigrationCutoverDialog { const migrationStatusTextValue = this._model.migrationStatus.properties.migrationStatus ? this._model.migrationStatus.properties.migrationStatus : this._model.migrationStatus.properties.provisioningState; - let fullBackupFileName: string; let lastAppliedSSN: string; let lastAppliedBackupFileTakenOn: string; @@ -486,19 +546,20 @@ export class MigrationCutoverDialog { const tableData: ActiveBackupFileSchema[] = []; this._model.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.forEach((activeBackupSet) => { + tableData.push( { fileName: activeBackupSet.listOfBackupFiles[0].fileName, type: activeBackupSet.backupType, status: activeBackupSet.listOfBackupFiles[0].status, + dataUploaded: `${convertByteSizeToReadableUnit(activeBackupSet.listOfBackupFiles[0].dataWritten)}/ ${convertByteSizeToReadableUnit(activeBackupSet.listOfBackupFiles[0].totalSize)}`, + copyThroughput: (activeBackupSet.listOfBackupFiles[0].copyThroughput / 1024).toFixed(2), backupStartTime: activeBackupSet.backupStartDate, firstLSN: activeBackupSet.firstLSN, lastLSN: activeBackupSet.lastLSN + } ); - if (activeBackupSet.listOfBackupFiles[0].fileName.substr(activeBackupSet.listOfBackupFiles[0].fileName.lastIndexOf('.') + 1) === 'bak') { - fullBackupFileName = activeBackupSet.listOfBackupFiles[0].fileName; - } if (activeBackupSet.listOfBackupFiles[0].fileName === this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename) { lastAppliedSSN = activeBackupSet.lastLSN; lastAppliedBackupFileTakenOn = activeBackupSet.backupFinishDate; @@ -514,7 +575,7 @@ export class MigrationCutoverDialog { this._targetVersion.value = targetServerVersion; this._migrationStatus.value = migrationStatusTextValue ?? '---'; - this._fullBackupFile.value = fullBackupFileName! ?? '-'; + this._fullBackupFile.value = this._model.migrationStatus?.properties?.migrationStatusDetails?.fullBackupSetInfo?.listOfBackupFiles[0]?.fileName! ?? '-'; let backupLocation; const isBlobMigration = this._model._migration.migrationContext.properties.backupConfiguration.sourceLocation?.azureBlob !== undefined; @@ -532,7 +593,7 @@ export class MigrationCutoverDialog { this._lastAppliedLSN.value = lastAppliedSSN! ?? '-'; this._lastAppliedBackupFile.value = this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename ?? '-'; - this._lastAppliedBackupTakenOn.value = lastAppliedBackupFileTakenOn! ? new Date(lastAppliedBackupFileTakenOn).toLocaleString() : '-'; + this._lastAppliedBackupTakenOn.value = lastAppliedBackupFileTakenOn! ? convertIsoTimeToLocalTime(lastAppliedBackupFileTakenOn).toLocaleString() : '-'; this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length); @@ -544,7 +605,9 @@ export class MigrationCutoverDialog { row.fileName, row.type, row.status, - new Date(row.backupStartTime).toLocaleString(), + row.dataUploaded, + row.copyThroughput, + convertIsoTimeToLocalTime(row.backupStartTime).toLocaleString(), row.firstLSN, row.lastLSN ]; @@ -578,7 +641,8 @@ export class MigrationCutoverDialog { value: label, CSSStyles: { 'font-weight': 'bold', - 'margin-bottom': '0' + 'margin-bottom': '0', + 'font-size': '12px' } }).component(); flexContainer.addItem(labelComponent); @@ -590,7 +654,8 @@ export class MigrationCutoverDialog { 'margin-bottom': '0', 'width': '100%', 'overflow': 'hidden', - 'text-overflow': 'ellipses' + 'text-overflow': 'ellipses', + 'font-size': '12px' } }).component(); flexContainer.addItem(textComponent); @@ -610,6 +675,8 @@ interface ActiveBackupFileSchema { fileName: string, type: string, status: string, + dataUploaded: string, + copyThroughput: string, backupStartTime: string, firstLSN: string, lastLSN: string diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts index 28cc48c7d8..dd79ca6025 100644 --- a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts @@ -10,9 +10,11 @@ import { MigrationContext, MigrationLocalStorage } from '../../models/migrationL import { MigrationCutoverDialog } from '../migrationCutover/migrationCutoverDialog'; import { AdsMigrationStatus, MigrationStatusDialogModel } from './migrationStatusDialogModel'; import * as loc from '../../constants/strings'; -import { convertTimeDifferenceToDuration, filterMigrations } from '../../api/utils'; +import { convertTimeDifferenceToDuration, filterMigrations, SupportedAutoRefreshIntervals } from '../../api/utils'; import { SqlMigrationServiceDetailsDialog } from '../sqlMigrationService/sqlMigrationServiceDetailsDialog'; +const refreshFrequency: SupportedAutoRefreshIntervals = 180000; + export class MigrationStatusDialog { private _model: MigrationStatusDialogModel; private _dialogObject!: azdata.window.Dialog; @@ -22,6 +24,7 @@ export class MigrationStatusDialog { private _statusDropdown!: azdata.DropDownComponent; private _statusTable!: azdata.DeclarativeTableComponent; private _refreshLoader!: azdata.LoadingComponent; + private _autoRefreshHandle!: NodeJS.Timeout; constructor(migrations: MigrationContext[], private _filter: AdsMigrationStatus) { this._model = new MigrationStatusDialogModel(migrations); @@ -65,9 +68,17 @@ export class MigrationStatusDialog { } ); const form = formBuilder.withLayout({ width: '100%' }).component(); + this._view.onClosed(e => { + clearInterval(this._autoRefreshHandle); + }); return view.initializeModel(form); }); this._dialogObject.content = [tab]; + this._dialogObject.cancelButton.hidden = true; + this._dialogObject.okButton.label = loc.CLOSE; + this._dialogObject.okButton.onClick(e => { + clearInterval(this._autoRefreshHandle); + }); azdata.window.openDialog(this._dialogObject); } @@ -97,9 +108,10 @@ export class MigrationStatusDialog { }); const flexContainer = this._view.modelBuilder.flexContainer().withProps({ + width: 900, CSSStyles: { 'justify-content': 'left' - } + }, }).component(); flexContainer.addItem(this._searchBox, { @@ -124,8 +136,25 @@ export class MigrationStatusDialog { 'margin-left': '20px' } }); + this.setAutoRefresh(refreshFrequency); + const container = this._view.modelBuilder.flexContainer().withProps({ + width: 1000 + }).component(); + container.addItem(flexContainer, { + flex: '0 0 auto', + CSSStyles: { + 'width': '980px' + } + }); + return container; + } - return flexContainer; + private setAutoRefresh(interval: SupportedAutoRefreshIntervals): void { + let classVariable = this; + clearInterval(this._autoRefreshHandle); + if (interval !== -1) { + this._autoRefreshHandle = setInterval(function () { classVariable.refreshTable(); }, interval); + } } private populateMigrationTable(): void { @@ -277,7 +306,7 @@ export class MigrationStatusDialog { { displayName: loc.TARGET_AZURE_SQL_INSTANCE_NAME, valueType: azdata.DeclarativeDataType.component, - width: '160px', + width: '130px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles diff --git a/extensions/sql-migration/src/models/migrationLocalStorage.ts b/extensions/sql-migration/src/models/migrationLocalStorage.ts index 663acac08c..475cb0704f 100644 --- a/extensions/sql-migration/src/models/migrationLocalStorage.ts +++ b/extensions/sql-migration/src/models/migrationLocalStorage.ts @@ -7,7 +7,6 @@ import { azureResource } from 'azureResource'; import { DatabaseMigration, SqlMigrationService, SqlManagedInstance, getMigrationStatus, AzureAsyncOperationResource, getMigrationAsyncOperationDetails, SqlVMServer } from '../api/azure'; import * as azdata from 'azdata'; - export class MigrationLocalStorage { private static context: vscode.ExtensionContext; private static mementoToken: string = 'sqlmigration.databaseMigrations'; diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 250422a99c..50b19af92c 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -122,6 +122,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _targetDatabaseNames!: string[]; public _serverDatabases!: string[]; + public _sqlMigrationServiceResourceGroup!: string; public _sqlMigrationService!: SqlMigrationService; public _sqlMigrationServices!: SqlMigrationService[]; public _nodeNames!: string[]; diff --git a/extensions/sql-migration/src/wizard/databaseBackupPage.ts b/extensions/sql-migration/src/wizard/databaseBackupPage.ts index 4e8d903539..ee184f267b 100644 --- a/extensions/sql-migration/src/wizard/databaseBackupPage.ts +++ b/extensions/sql-migration/src/wizard/databaseBackupPage.ts @@ -684,6 +684,7 @@ export class DatabaseBackupPage extends MigrationWizardPage { }).component(); targetDatabaseInput.onTextChanged((value) => { this.migrationStateModel._targetDatabaseNames[index] = value.trim(); + this.validateFields(); }); this._networkShareTargetDatabaseNames.push(targetDatabaseInput); @@ -749,7 +750,7 @@ export class DatabaseBackupPage extends MigrationWizardPage { fireOnTextChange: true, }).component(); blobContainerDropdown.onValueChanged(value => { - const selectedIndex = findDropDownItemIndex(blobContainerStorageAccountDropdown, value); + const selectedIndex = findDropDownItemIndex(blobContainerDropdown, value); if (selectedIndex > -1 && value !== constants.NO_BLOBCONTAINERS_FOUND) { this.migrationStateModel._databaseBackup.blobs[index].blobContainer = this.migrationStateModel.getBlobContainer(selectedIndex); } @@ -830,23 +831,24 @@ export class DatabaseBackupPage extends MigrationWizardPage { } }); - const duplicates: Map = new Map(); - for (let i = 0; i < this.migrationStateModel._targetDatabaseNames.length; i++) { - const blobContainerId = this.migrationStateModel._databaseBackup.blobs[i].blobContainer.id; - if (duplicates.has(blobContainerId)) { - duplicates.get(blobContainerId)?.push(i); - } else { - duplicates.set(blobContainerId, [i]); + if (errors.length > 0) { + const duplicates: Map = new Map(); + for (let i = 0; i < this.migrationStateModel._targetDatabaseNames.length; i++) { + const blobContainerId = this.migrationStateModel._databaseBackup.blobs[i].blobContainer?.id; + if (duplicates.has(blobContainerId)) { + duplicates.get(blobContainerId)?.push(i); + } else { + duplicates.set(blobContainerId, [i]); + } } + duplicates.forEach((d) => { + if (d.length > 1) { + const dupString = `${d.map(index => this.migrationStateModel._migrationDbs[index]).join(', ')}`; + errors.push(constants.PROVIDE_UNIQUE_CONTAINERS + dupString); + } + }); } - duplicates.forEach((d) => { - if (d.length > 1) { - const dupString = `${d.map(index => this.migrationStateModel._migrationDbs[index]).join(', ')}`; - errors.push(constants.PROVIDE_UNIQUE_CONTAINERS + dupString); - } - }); - break; } diff --git a/extensions/sql-migration/src/wizard/integrationRuntimePage.ts b/extensions/sql-migration/src/wizard/integrationRuntimePage.ts index 358fe3214f..0b26df61e8 100644 --- a/extensions/sql-migration/src/wizard/integrationRuntimePage.ts +++ b/extensions/sql-migration/src/wizard/integrationRuntimePage.ts @@ -6,11 +6,11 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { MigrationWizardPage } from '../models/migrationWizardPage'; -import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; +import { MigrationStateModel, NetworkContainerType, StateChangeEvent } from '../models/stateMachine'; import { CreateSqlMigrationServiceDialog } from '../dialog/createSqlMigrationService/createSqlMigrationServiceDialog'; import * as constants from '../constants/strings'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; -import { getLocationDisplayName, getSqlMigrationService, getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData, SqlManagedInstance, SqlMigrationService } from '../api/azure'; +import { getLocationDisplayName, getSqlMigrationService, getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData, SqlManagedInstance } from '../api/azure'; import { IconPathHelper } from '../constants/iconPathHelper'; import { findDropDownItemIndex } from '../api/utils'; @@ -24,6 +24,7 @@ export class IntergrationRuntimePage extends MigrationWizardPage { private _resourceGroupDropdown!: azdata.DropDownComponent; private _dmsDropdown!: azdata.DropDownComponent; + private _dmsInfoContainer!: azdata.FlexContainer; private _dmsStatusInfoBox!: azdata.InfoBoxComponent; private _authKeyTable!: azdata.DeclarativeTableComponent; private _refreshButton!: azdata.ButtonComponent; @@ -34,8 +35,6 @@ export class IntergrationRuntimePage extends MigrationWizardPage { private _refresh1!: azdata.ButtonComponent; private _refresh2!: azdata.ButtonComponent; - private _firstEnter: boolean = true; - constructor(wizard: azdata.window.Wizard, migrationStateModel: MigrationStateModel) { super(wizard, azdata.window.createWizardPage(constants.IR_PAGE_TITLE), migrationStateModel); } @@ -51,13 +50,20 @@ export class IntergrationRuntimePage extends MigrationWizardPage { } }).component(); - createNewMigrationService.onDidClick((e) => { - const dialog = new CreateSqlMigrationServiceDialog(this.migrationStateModel, this); - dialog.initialize(); + createNewMigrationService.onDidClick(async (e) => { + const dialog = new CreateSqlMigrationServiceDialog(); + const createdDmsResult = await dialog.createNewDms(this.migrationStateModel, (this._resourceGroupDropdown.value).displayName); + this.migrationStateModel._sqlMigrationServiceResourceGroup = createdDmsResult.resourceGroup; + this.migrationStateModel._sqlMigrationService = createdDmsResult.service; + await this.loadResourceGroupDropdown(); + await this.populateDms(createdDmsResult.resourceGroup); }); this._statusLoadingComponent = view.modelBuilder.loadingComponent().withItem(this.createDMSDetailsContainer()).component(); + this._dmsInfoContainer = this._view.modelBuilder.flexContainer().withItems([ + this._statusLoadingComponent + ]).component(); const dmsPortalInfo = this._view.modelBuilder.infoBox().withProps({ text: constants.DMS_PORTAL_INFO, style: 'information', @@ -80,7 +86,7 @@ export class IntergrationRuntimePage extends MigrationWizardPage { component: dmsPortalInfo }, { - component: this._statusLoadingComponent + component: this._dmsInfoContainer } ] @@ -89,10 +95,11 @@ export class IntergrationRuntimePage extends MigrationWizardPage { } public async onPageEnter(): Promise { - if (this._firstEnter) { - this.populateMigrationService(); - this._firstEnter = false; - } + + this._subscription.value = this.migrationStateModel._targetSubscription.name; + this._location.value = await getLocationDisplayName(this.migrationStateModel._targetServerInstance.location); + this.loadResourceGroupDropdown(); + this._dmsInfoContainer.display = (this.migrationStateModel._databaseBackup.networkContainerType === NetworkContainerType.NETWORK_SHARE) ? 'inline' : 'none'; this.wizard.registerNavigationValidator((pageChangeInfo) => { if (pageChangeInfo.newPage < pageChangeInfo.lastPage) { this.wizard.message = { @@ -108,7 +115,7 @@ export class IntergrationRuntimePage extends MigrationWizardPage { }; return false; } - if (state !== 'Online') { + if (this.migrationStateModel._databaseBackup.networkContainerType === NetworkContainerType.NETWORK_SHARE && state !== 'Online') { this.wizard.message = { level: azdata.window.MessageLevel.Error, text: constants.SERVICE_OFFLINE_ERROR @@ -202,6 +209,9 @@ export class IntergrationRuntimePage extends MigrationWizardPage { this._dmsDropdown.onValueChanged(async (value) => { if (value && value !== constants.SQL_MIGRATION_SERVICE_NOT_FOUND_ERROR) { + if (this.migrationStateModel._databaseBackup.networkContainerType === NetworkContainerType.NETWORK_SHARE) { + this._dmsInfoContainer.display = 'inline'; + } this.wizard.message = { text: '' }; @@ -210,6 +220,8 @@ export class IntergrationRuntimePage extends MigrationWizardPage { this.migrationStateModel._sqlMigrationService = this.migrationStateModel.getMigrationService(selectedIndex); await this.loadMigrationServiceStatus(); } + } else { + this._dmsInfoContainer.display = 'none'; } }); @@ -254,8 +266,11 @@ export class IntergrationRuntimePage extends MigrationWizardPage { this._refreshButton.onDidClick(async (e) => { this._connectionStatusLoader.loading = true; - await this.loadStatus(); - this._connectionStatusLoader.loading = false; + try { + await this.loadStatus(); + } finally { + this._connectionStatusLoader.loading = false; + } }); const connectionLabelContainer = this._view.modelBuilder.flexContainer().withProps({ @@ -387,53 +402,24 @@ export class IntergrationRuntimePage extends MigrationWizardPage { return container; } - public async populateMigrationService(sqlMigrationService?: SqlMigrationService, serviceNodes?: string[], resourceGroupName?: string): Promise { + + public async loadResourceGroupDropdown(): Promise { this._resourceGroupDropdown.loading = true; - this._dmsDropdown.loading = true; - if (sqlMigrationService && serviceNodes) { - this.migrationStateModel._sqlMigrationService = sqlMigrationService; - this.migrationStateModel._nodeNames = serviceNodes; - } try { - this._subscription.value = this.migrationStateModel._targetSubscription.name; - this._location.value = await getLocationDisplayName(this.migrationStateModel._targetServerInstance.location); this._resourceGroupDropdown.values = await this.migrationStateModel.getAzureResourceGroupDropdownValues(this.migrationStateModel._targetSubscription); - - let index = 0; - if (resourceGroupName) { - index = findDropDownItemIndex(this._resourceGroupDropdown, resourceGroupName); - } - - if ((this._resourceGroupDropdown.value)?.displayName.toLowerCase() === (this._resourceGroupDropdown.values[index])?.displayName.toLowerCase()) { - await this.populateDms((this._resourceGroupDropdown.value)?.displayName); - } else { - this._resourceGroupDropdown.value = this._resourceGroupDropdown.values[index]; - } - } catch (error) { - console.log(error); + const resourceGroupDropdownValue = this._resourceGroupDropdown.values.find(v => v.displayName === this.migrationStateModel._sqlMigrationServiceResourceGroup); + this._resourceGroupDropdown.value = (resourceGroupDropdownValue) ? resourceGroupDropdownValue : this._resourceGroupDropdown.values[0]; } finally { this._resourceGroupDropdown.loading = false; } } public async populateDms(resourceGroupName: string): Promise { - if (!resourceGroupName) { - return; - } this._dmsDropdown.loading = true; try { this._dmsDropdown.values = await this.migrationStateModel.getSqlMigrationServiceValues(this.migrationStateModel._targetSubscription, this.migrationStateModel._targetServerInstance, resourceGroupName); - let index = -1; - if (this.migrationStateModel._sqlMigrationService) { - index = findDropDownItemIndex(this._dmsDropdown, this.migrationStateModel._sqlMigrationService.name); - } - if (index > -1) { - this._dmsDropdown.value = this._dmsDropdown.values[index]; - } else { - this._dmsDropdown.value = this._dmsDropdown.values[0]; - } - } catch (e) { - console.log(e); + const selectedSqlMigrationService = this._dmsDropdown.values.find(v => v.displayName.toLowerCase() === this.migrationStateModel._sqlMigrationService?.name.toLowerCase()); + this._dmsDropdown.value = (selectedSqlMigrationService) ? selectedSqlMigrationService : this._dmsDropdown.values[0]; } finally { this._dmsDropdown.loading = false; } diff --git a/extensions/sql-migration/src/wizard/summaryPage.ts b/extensions/sql-migration/src/wizard/summaryPage.ts index 45cffa0156..65a931291a 100644 --- a/extensions/sql-migration/src/wizard/summaryPage.ts +++ b/extensions/sql-migration/src/wizard/summaryPage.ts @@ -61,10 +61,13 @@ export class SummaryPage extends MigrationWizardPage { createInformationRow(this._view, constants.LOCATION, this.migrationStateModel._sqlMigrationService.location), createInformationRow(this._view, constants.SUBSCRIPTION, this.migrationStateModel._sqlMigrationService.properties.resourceGroup), createInformationRow(this._view, constants.IR_PAGE_TITLE, this.migrationStateModel._targetSubscription.name), - createInformationRow(this._view, constants.SUBSCRIPTION, this.migrationStateModel._sqlMigrationService.name), - createInformationRow(this._view, constants.SHIR, this.migrationStateModel._nodeNames[0]), + createInformationRow(this._view, constants.SUBSCRIPTION, this.migrationStateModel._sqlMigrationService.name) ] ); + + if (this.migrationStateModel._databaseBackup.networkContainerType === NetworkContainerType.NETWORK_SHARE && this.migrationStateModel._nodeNames.length > 0) { + this._flexContainer.addItem(createInformationRow(this._view, constants.SHIR, this.migrationStateModel._nodeNames.join(', '))); + } } public async onPageLeave(): Promise {