From 78a144b5cab6b44b816285fda3ac2f2b16e2c44b Mon Sep 17 00:00:00 2001 From: brian-harris <61598682+brian-harris@users.noreply.github.com> Date: Fri, 16 Jul 2021 12:20:19 -0700 Subject: [PATCH] sql-migration: update migration status page latest design (#16099) * add migrations status context menu and commands * add migration status images * remove test code, add account validation * fix command registration to occure once * fix typo --- extensions/sql-migration/package.json | 62 +++ extensions/sql-migration/package.nls.json | 9 +- .../sql-migration/src/constants/strings.ts | 49 +- .../migrationCutoverDialog.ts | 474 +++++++++--------- .../migrationStatus/migrationStatusDialog.ts | 448 ++++++++++++----- .../src/wizard/accountsSelectionPage.ts | 22 +- 6 files changed, 684 insertions(+), 380 deletions(-) diff --git a/extensions/sql-migration/package.json b/extensions/sql-migration/package.json index f35cde9d92..3fe7eb809a 100644 --- a/extensions/sql-migration/package.json +++ b/extensions/sql-migration/package.json @@ -44,8 +44,70 @@ "command": "sqlmigration.openNotebooks", "title": "%migration-notebook-command-title%", "category": "%migration-command-category%" + }, + { + "command": "sqlmigration.cutover", + "title": "%complete-cutover-menu%", + "category": "%migration-context-menu-category%" + }, + { + "command": "sqlmigration.view.database", + "title": "%database-details-menu%", + "category": "%migration-context-menu-category%" + }, + { + "command": "sqlmigration.view.target", + "title": "%view-target-menu%", + "category": "%migration-context-menu-category%" + }, + { + "command": "sqlmigration.view.service", + "title": "%view-service-menu%", + "category": "%migration-context-menu-category%" + }, + { + "command": "sqlmigration.copy.migration", + "title": "%copy-migration-menu%", + "category": "%migration-context-menu-category%" + }, + { + "command": "sqlmigration.cancel.migration", + "title": "%cancel-migration-menu%", + "category": "%migration-context-menu-category%" } ], + "menu": { + "commandPalette": [ + { + "command": "sqlmigration.sendfeedback", + "when": "false" + }, + { + "command": "sqlmigration.cutover", + "when": "false" + }, + { + "command": "sqlmigration.view.database", + "when": "false" + }, + { + "command": "sqlmigration.view.target", + "when": "false" + }, + { + "command": "sqlmigration.view.service", + "when": "false" + }, + { + "command": "sqlmigration.copy.migration", + "when": "false" + }, + { + "command": "sqlmigration.cancel.migration", + "when": "false" + } + ] + }, "dashboard.tabs": [ { "id": "migration-dashboard", diff --git a/extensions/sql-migration/package.nls.json b/extensions/sql-migration/package.nls.json index e29db19b83..48c3fb883a 100644 --- a/extensions/sql-migration/package.nls.json +++ b/extensions/sql-migration/package.nls.json @@ -6,5 +6,12 @@ "migration-dashboard-tasks": "Migration Tasks", "migration-command-category": "Azure SQL Migration", "start-migration-command": "Migrate to Azure SQL", - "send-feedback-command": "Feedback" + "send-feedback-command": "Feedback", + "migration-context-menu-category": "Migration Context Menu", + "complete-cutover-menu": "Complete cutover", + "database-details-menu": "Database details", + "view-target-menu": "Azure SQL Target details", + "view-service-menu": "Dataase Migration Service details", + "copy-migration-menu": "Copy migration details", + "cancel-migration-menu": "Cancel migration" } diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 69f6d9846e..87b19e13f4 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -77,6 +77,9 @@ export const AZURE_TENANT = localize('sql.migration.azure.tenant', "Azure AD ten export function ACCOUNT_STALE_ERROR(account: AzureAccount) { return localize('azure.accounts.accountStaleError', "The access token for selected account '{0}' is no longer valid. Please click the 'Link Account' button and refresh the account or select a different account.", `${account.displayInfo.displayName} (${account.displayInfo.userId})`); } +export function ACCOUNT_ACCESS_ERROR(account: AzureAccount, error: Error) { + return localize('azure.accounts.accountAccessError', "An error occurred while accessing the selected account '{0}'. Please click the 'Link Account' button and refresh the account or select a different account. Error '{1}'", `${account.displayInfo.displayName} (${account.displayInfo.userId})`, error.message); +} // database backup page export const DATABASE_BACKUP_PAGE_TITLE = localize('sql.migration.database.page.title', "Database Backup"); @@ -330,7 +333,7 @@ export const SOURCE_VERSION = localize('sql.migration.source.version', "Source v export const TARGET_DATABASE_NAME = localize('sql.migration.target.database.name', "Target database name"); export const TARGET_SERVER = localize('sql.migration.target.server', "Target server"); export const TARGET_VERSION = localize('sql.migration.target.version', "Target version"); -export const MIGRATION_STATUS = localize('sql.migration.migration.status', "Migration status"); +export const MIGRATION_STATUS = localize('sql.migration.migration.status', "Migration Status"); export const MIGRATION_STATUS_FILTER = localize('sql.migration.migration.status.filter', "Migration status filter"); export const FULL_BACKUP_FILES = localize('sql.migration.full.backup.files', "Full backup files"); export const LAST_APPLIED_LSN = localize('sql.migration.last.applied.lsn', "Last applied LSN"); @@ -372,6 +375,9 @@ export const CONFIRM_CUTOVER_CHECKBOX = localize('sql.migration.confirm.checkbox export function CUTOVER_IN_PROGRESS(dbName: string): string { return localize('sql.migration.cutover.in.progress', "Cutover in progress for database '{0}'", dbName); } +export const MIGRATION_CANNOT_CANCEL = localize('sql.migration.cannot.cancel', 'Migration is not in progress and cannot be cancelled.'); +export const MIGRATION_CANNOT_CUTOVER = localize('sql.migration.cannot.cutover', 'Migration is not in progress and cannot be cutover.'); + //Migration status dialog export const SEARCH_FOR_MIGRATIONS = localize('sql.migration.search.for.migration', "Search for migrations"); export const ONLINE = localize('sql.migration.online', "Online"); @@ -386,24 +392,47 @@ export const TARGET_AZURE_SQL_INSTANCE_NAME = localize('sql.migration.target.azu export const MIGRATION_MODE = localize('sql.migration.cutover.type', "Migration Mode"); export const START_TIME = localize('sql.migration.start.time', "Start Time"); export const FINISH_TIME = localize('sql.migration.finish.time', "Finish Time"); -export function STATUS_WARNING_COUNT(status: string, count: number): string { - if (status === 'InProgress' || status === 'Creating' || status === 'Completing' || status === 'Creating') { + +export function STATUS_VALUE(status: string, count: number): string { + if (count > 0) { + return localize('sql.migration.status.error.count.some', "{0} (", StatusLookup[status]); + } + + return localize('sql.migration.status.error.count.none', "{0}", StatusLookup[status]); +} + +export interface LookupTable { + [key: string]: T; +} + +export const StatusLookup: LookupTable = { + ['InProgress']: localize('sql.migration.status.inprogress', 'In progress'), + ['Succeeded']: localize('sql.migration.status.succeeded', 'Succeeded'), + ['Creating']: localize('sql.migration.status.creating', 'Creating'), + ['Completing']: localize('sql.migration.status.completing', 'Completing'), + ['Cancelling']: localize('sql.migration.status.cancelling', 'Cancelling'), + ['Failed']: localize('sql.migration.status.failed', 'Failed'), + default: undefined, +}; + +export function STATUS_WARNING_COUNT(status: string, count: number): string | undefined { + if (status === 'InProgress' || status === 'Creating' || status === 'Completing') { switch (count) { case 0: - return localize('sql.migration.status.warning.count.none', "{0}", status); + return undefined; case 1: - return localize('sql.migration.status.warning.count.single', "{0} ({1} Warning)", status, count); + return localize('sql.migration.status.warning.count.single', "{0} Warning)", count); default: - return localize('sql.migration.status.warning.count.multiple', "{0} ({1} Warnings)", status, count); + return localize('sql.migration.status.warning.count.multiple', "{0} Warnings)", count); } } else { switch (count) { case 0: - return localize('sql.migration.status.error.count.none', "{0}", status); + return undefined; case 1: - return localize('sql.migration.status.error.count.single', "{0} ({1} Error)", status, count); + return localize('sql.migration.status.error.count.single', "{0} Error)", count); default: - return localize('sql.migration.status.error.count.multiple', "{0} ({1} Errors)", status, count); + return localize('sql.migration.status.error.count.multiple', "{0} Errors)", count); } } } @@ -474,6 +503,8 @@ export const AUTHENTICATION_TYPE = localize('sql.migration.authentication.type', export const SQL_LOGIN = localize('sql.migration.sql.login', "SQL Login"); export const WINDOWS_AUTHENTICATION = localize('sql.migration.windows.auth', "Windows Authentication"); +export const REFRESH_BUTTON_LABEL = localize('sql.migration.status.refresh.label', 'Refresh'); + //AutoRefresh export function AUTO_REFRESH_BUTTON_TEXT(interval: SupportedAutoRefreshIntervals): string { switch (interval) { diff --git a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts index 2b7c7b5e7a..1e5887aafa 100644 --- a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts @@ -39,13 +39,11 @@ export class MigrationCutoverDialog { private _lastAppliedLSN!: azdata.TextComponent; private _lastAppliedBackupFile!: azdata.TextComponent; private _lastAppliedBackupTakenOn!: azdata.TextComponent; - private _fileCount!: azdata.TextComponent; - private fileTable!: azdata.TableComponent; private _autoRefreshHandle!: any; - readonly _infoFieldWidth: string = '250px'; + readonly _infoFieldWidth: string = '250px'; constructor(migration: MigrationContext) { this._model = new MigrationCutoverDialogModel(migration); @@ -55,244 +53,233 @@ export class MigrationCutoverDialog { async initialize(): Promise { let tab = azdata.window.createTab(''); tab.registerContent(async (view: azdata.ModelView) => { - this._view = view; - const sourceDatabase = this.createInfoField(loc.SOURCE_DATABASE, ''); - const sourceDetails = this.createInfoField(loc.SOURCE_SERVER, ''); - const sourceVersion = this.createInfoField(loc.SOURCE_VERSION, ''); + try { + this._view = view; + const sourceDatabase = this.createInfoField(loc.SOURCE_DATABASE, ''); + const sourceDetails = this.createInfoField(loc.SOURCE_SERVER, ''); + const sourceVersion = this.createInfoField(loc.SOURCE_VERSION, ''); - this._sourceDatabase = sourceDatabase.text; - this._serverName = sourceDetails.text; - this._serverVersion = sourceVersion.text; + this._sourceDatabase = sourceDatabase.text; + this._serverName = sourceDetails.text; + this._serverVersion = sourceVersion.text; - const flexServer = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); + const flexServer = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); - flexServer.addItem(sourceDatabase.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexServer.addItem(sourceDetails.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexServer.addItem(sourceVersion.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - - const targetDatabase = this.createInfoField(loc.TARGET_DATABASE_NAME, ''); - const targetServer = this.createInfoField(loc.TARGET_SERVER, ''); - const targetVersion = this.createInfoField(loc.TARGET_VERSION, ''); - - this._targetDatabase = targetDatabase.text; - this._targetServer = targetServer.text; - this._targetVersion = targetVersion.text; - - const flexTarget = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - - flexTarget.addItem(targetDatabase.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexTarget.addItem(targetServer.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexTarget.addItem(targetVersion.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - - const migrationStatus = this.createInfoField(loc.MIGRATION_STATUS, ''); - const fullBackupFileOn = this.createInfoField(loc.FULL_BACKUP_FILES, ''); - const backupLocation = this.createInfoField(loc.BACKUP_LOCATION, ''); - - - this._migrationStatus = migrationStatus.text; - this._fullBackupFile = fullBackupFileOn.text; - this._backupLocation = backupLocation.text; - - const flexStatus = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - - flexStatus.addItem(migrationStatus.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexStatus.addItem(fullBackupFileOn.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexStatus.addItem(backupLocation.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - - const lastSSN = this.createInfoField(loc.LAST_APPLIED_LSN, ''); - const lastAppliedBackup = this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, ''); - const lastAppliedBackupOn = this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES_TAKEN_ON, ''); - - - this._lastAppliedLSN = lastSSN.text; - this._lastAppliedBackupFile = lastAppliedBackup.text; - this._lastAppliedBackupTakenOn = lastAppliedBackupOn.text; - - const flexFile = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - flexFile.addItem(lastSSN.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexFile.addItem(lastAppliedBackup.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - flexFile.addItem(lastAppliedBackupOn.flexContainer, { - CSSStyles: { - 'width': this._infoFieldWidth - } - }); - const flexInfo = view.modelBuilder.flexContainer().withProps({ - width: 1000 - }).component(); - - flexInfo.addItem(flexServer, { - flex: '0', - CSSStyles: { - 'flex': '0', - 'width': this._infoFieldWidth - } - }); - - flexInfo.addItem(flexTarget, { - flex: '0', - CSSStyles: { - 'flex': '0', - 'width': this._infoFieldWidth - } - }); - - flexInfo.addItem(flexStatus, { - flex: '0', - CSSStyles: { - 'flex': '0', - 'width': this._infoFieldWidth - } - }); - - flexInfo.addItem(flexFile, { - flex: '0', - CSSStyles: { - 'flex': '0', - 'width': this._infoFieldWidth - } - }); - - this._fileCount = view.modelBuilder.text().withProps({ - width: '500px', - CSSStyles: { - 'font-size': '14px', - 'font-weight': 'bold' - } - }).component(); - - this.fileTable = view.modelBuilder.table().withProps({ - columns: [ - { - value: loc.ACTIVE_BACKUP_FILES, - width: 230, - type: azdata.ColumnType.text, - }, - { - value: loc.TYPE, - width: 90, - type: azdata.ColumnType.text - }, - { - value: loc.STATUS, - 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 + flexServer.addItem(sourceDatabase.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth } - ], - data: [], - width: '1100px', - height: '300px', - fontSize: '12px' - }).component(); - - const formBuilder = view.modelBuilder.formContainer().withFormItems( - [ - { - component: this.migrationContainerHeader() - }, - { - component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() - }, - { - component: flexInfo - }, - { - component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() - }, - { - component: this._fileCount - }, - { - component: this.fileTable + }); + flexServer.addItem(sourceDetails.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth } - ], - { - horizontal: false - } - ); - const form = formBuilder.withLayout({ width: '100%' }).component(); - this._view.onClosed(e => { - clearInterval(this._autoRefreshHandle); - }); - return view.initializeModel(form).then((value) => { - this.refreshStatus(); - }); + }); + flexServer.addItem(sourceVersion.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + + const targetDatabase = this.createInfoField(loc.TARGET_DATABASE_NAME, ''); + const targetServer = this.createInfoField(loc.TARGET_SERVER, ''); + const targetVersion = this.createInfoField(loc.TARGET_VERSION, ''); + + this._targetDatabase = targetDatabase.text; + this._targetServer = targetServer.text; + this._targetVersion = targetVersion.text; + + const flexTarget = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + + flexTarget.addItem(targetDatabase.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + flexTarget.addItem(targetServer.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + flexTarget.addItem(targetVersion.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + + const migrationStatus = this.createInfoField(loc.MIGRATION_STATUS, ''); + const fullBackupFileOn = this.createInfoField(loc.FULL_BACKUP_FILES, ''); + const backupLocation = this.createInfoField(loc.BACKUP_LOCATION, ''); + + this._migrationStatus = migrationStatus.text; + this._fullBackupFile = fullBackupFileOn.text; + this._backupLocation = backupLocation.text; + + const flexStatus = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + + flexStatus.addItem(migrationStatus.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + flexStatus.addItem(fullBackupFileOn.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + flexStatus.addItem(backupLocation.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + + const lastSSN = this.createInfoField(loc.LAST_APPLIED_LSN, ''); + const lastAppliedBackup = this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, ''); + const lastAppliedBackupOn = this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES_TAKEN_ON, ''); + + this._lastAppliedLSN = lastSSN.text; + this._lastAppliedBackupFile = lastAppliedBackup.text; + this._lastAppliedBackupTakenOn = lastAppliedBackupOn.text; + + const flexFile = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + flexFile.addItem(lastSSN.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + flexFile.addItem(lastAppliedBackup.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + flexFile.addItem(lastAppliedBackupOn.flexContainer, { + CSSStyles: { + 'width': this._infoFieldWidth + } + }); + const flexInfo = view.modelBuilder.flexContainer().withProps({ + width: 1000 + }).component(); + + flexInfo.addItem(flexServer, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': this._infoFieldWidth + } + }); + + flexInfo.addItem(flexTarget, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': this._infoFieldWidth + } + }); + + flexInfo.addItem(flexStatus, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': this._infoFieldWidth + } + }); + + flexInfo.addItem(flexFile, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': this._infoFieldWidth + } + }); + + this._fileCount = view.modelBuilder.text().withProps({ + width: '500px', + CSSStyles: { + 'font-size': '14px', + 'font-weight': 'bold' + } + }).component(); + + this.fileTable = view.modelBuilder.table().withProps({ + columns: [ + { + value: loc.ACTIVE_BACKUP_FILES, + width: 230, + type: azdata.ColumnType.text, + }, + { + value: loc.TYPE, + width: 90, + type: azdata.ColumnType.text + }, + { + value: loc.STATUS, + 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: '1100px', + height: '300px', + fontSize: '12px' + }).component(); + + const formBuilder = view.modelBuilder.formContainer().withFormItems( + [ + { component: this.migrationContainerHeader() }, + { component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() }, + { component: flexInfo }, + { component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() }, + { component: this._fileCount }, + { component: this.fileTable } + ], + { horizontal: false } + ); + const form = formBuilder.withLayout({ width: '100%' }).component(); + this._view.onClosed(e => { + clearInterval(this._autoRefreshHandle); + }); + + return view.initializeModel(form).then((value) => { + this.refreshStatus(); + }); + } catch (e) { + console.log(e); + } }); this._dialogObject.content = [tab]; @@ -305,7 +292,6 @@ export class MigrationCutoverDialog { azdata.window.openDialog(this._dialogObject); } - private migrationContainerHeader(): azdata.FlexContainer { const sqlDatbaseLogo = this._view.modelBuilder.image().withProps({ iconPath: IconPathHelper.sqlDatabaseLogo, @@ -404,7 +390,7 @@ export class MigrationCutoverDialog { this._cancelButton.onDidClick((e) => { vscode.window.showInformationMessage(loc.CANCEL_MIGRATION_CONFIRMATION, loc.YES, loc.NO).then(async (v) => { if (v === loc.YES) { - await this.cancelMigration(); + await this._model.cancelMigration(); await this.refreshStatus(); } }); @@ -427,9 +413,8 @@ export class MigrationCutoverDialog { } }).component(); - this._refreshButton.onDidClick((e) => { - this.refreshStatus(); - }); + this._refreshButton.onDidClick( + async (e) => await this.refreshStatus()); headerActions.addItem(this._refreshButton, { flex: '0', @@ -597,7 +582,7 @@ export class MigrationCutoverDialog { this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length); - //Sorting files in descending order of backupStartTime + // Sorting files in descending order of backupStartTime tableData.sort((file1, file2) => new Date(file1.backupStartTime) > new Date(file2.backupStartTime) ? - 1 : 1); this.fileTable.data = tableData.map((row) => { @@ -664,11 +649,6 @@ export class MigrationCutoverDialog { text: textComponent }; } - - private async cancelMigration(): Promise { - await this._model.cancelMigration(); - await this.refreshStatus(); - } } interface ActiveBackupFileSchema { diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts index 1a342f41f4..2aff820d64 100644 --- a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts @@ -12,9 +12,15 @@ import { AdsMigrationStatus, MigrationStatusDialogModel } from './migrationStatu import * as loc from '../../constants/strings'; import { convertTimeDifferenceToDuration, filterMigrations, SupportedAutoRefreshIntervals } from '../../api/utils'; import { SqlMigrationServiceDetailsDialog } from '../sqlMigrationService/sqlMigrationServiceDetailsDialog'; +import { ConfirmCutoverDialog } from '../migrationCutover/confirmCutoverDialog'; +import { MigrationCutoverDialogModel } from '../migrationCutover/migrationCutoverDialogModel'; const refreshFrequency: SupportedAutoRefreshIntervals = 180000; +const statusImageSize: number = 14; +const imageCellStyles: azdata.CssStyles = { 'margin': '3px 3px 0 0', 'padding': '0' }; +const statusCellStyles: azdata.CssStyles = { 'margin': '0', 'padding': '0' }; + export class MigrationStatusDialog { private _model: MigrationStatusDialogModel; private _dialogObject!: azdata.window.Dialog; @@ -52,6 +58,7 @@ export class MigrationStatusDialog { }); } + this.registerCommands(); const formBuilder = view.modelBuilder.formContainer().withFormItems( [ { @@ -83,6 +90,9 @@ export class MigrationStatusDialog { azdata.window.openDialog(this._dialogObject); } + private canCancelMigration = (status: string | undefined) => status && status in ['InProgress', 'Creating', 'Completing', 'Creating']; + private canCutoverMigration = (status: string | undefined) => status === 'InProgress'; + private createSearchAndRefreshContainer(): azdata.FlexContainer { this._searchBox = this._view.modelBuilder.inputBox().withProps({ stopEnterPropagation: true, @@ -102,7 +112,7 @@ export class MigrationStatusDialog { iconHeight: '16px', iconWidth: '20px', height: '30px', - label: 'Refresh', + label: loc.REFRESH_BUTTON_LABEL, }).component(); this._refresh.onDidClick((e) => { @@ -152,122 +162,291 @@ export class MigrationStatusDialog { } private setAutoRefresh(interval: SupportedAutoRefreshIntervals): void { - let classVariable = this; + const classVariable = this; clearInterval(this._autoRefreshHandle); if (interval !== -1) { this._autoRefreshHandle = setInterval(function () { classVariable.refreshTable(); }, interval); } } - private populateMigrationTable(): void { - try { - const migrations = filterMigrations(this._model._migrations, (this._statusDropdown.value).name, this._searchBox.value!); + private registerCommands(): void { + vscode.commands.registerCommand( + 'sqlmigration.cutover', + async (migrationId: string) => { + try { + const migration = this._model._migrations.find(migration => migration.migrationContext.id === migrationId); + if (this.canCutoverMigration(migration?.migrationContext.properties.migrationStatus)) { + const cutoverDialogModel = new MigrationCutoverDialogModel(migration!); + await cutoverDialogModel.fetchStatus(); + const dialog = new ConfirmCutoverDialog(cutoverDialogModel); + await dialog.initialize(); + } else { + await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CUTOVER); + } + } catch (e) { + console.log(e); + } + }); - const data: azdata.DeclarativeTableCellValue[][] = []; + vscode.commands.registerCommand( + 'sqlmigration.view.database', + async (migrationId: string) => { + try { + const migration = this._model._migrations.find(migration => migration.migrationContext.id === migrationId); + const dialog = new MigrationCutoverDialog(migration!); + await dialog.initialize(); + } catch (e) { + console.log(e); + } + }); + + vscode.commands.registerCommand( + 'sqlmigration.view.target', + async (migrationId: string) => { + try { + const migration = this._model._migrations.find(migration => migration.migrationContext.id === migrationId); + const url = 'https://portal.azure.com/#resource/' + migration!.targetManagedInstance.id; + await vscode.env.openExternal(vscode.Uri.parse(url)); + } catch (e) { + console.log(e); + } + }); + + vscode.commands.registerCommand( + 'sqlmigration.view.service', + async (migrationId: string) => { + try { + const migration = this._model._migrations.find(migration => migration.migrationContext.id === migrationId); + const dialog = new SqlMigrationServiceDetailsDialog(migration!); + await dialog.initialize(); + } catch (e) { + console.log(e); + } + }); + + vscode.commands.registerCommand( + 'sqlmigration.copy.migration', + async (migrationId: string) => { + try { + const migration = this._model._migrations.find(migration => migration.migrationContext.id === migrationId); + const cutoverDialogModel = new MigrationCutoverDialogModel(migration!); + await cutoverDialogModel.fetchStatus(); + if (cutoverDialogModel.migrationOpStatus) { + await vscode.env.clipboard.writeText(JSON.stringify({ + 'async-operation-details': cutoverDialogModel.migrationOpStatus, + 'details': cutoverDialogModel.migrationStatus + }, undefined, 2)); + } else { + await vscode.env.clipboard.writeText(JSON.stringify(cutoverDialogModel.migrationStatus, undefined, 2)); + } + + await vscode.window.showInformationMessage(loc.DETAILS_COPIED); + } catch (e) { + console.log(e); + } + }); + + vscode.commands.registerCommand( + 'sqlmigration.cancel.migration', + async (migrationId: string) => { + try { + const migration = this._model._migrations.find(migration => migration.migrationContext.id === migrationId); + if (this.canCancelMigration(migration?.migrationContext.properties.migrationStatus)) { + vscode.window.showInformationMessage(loc.CANCEL_MIGRATION_CONFIRMATION, loc.YES, loc.NO).then(async (v) => { + if (v === loc.YES) { + const cutoverDialogModel = new MigrationCutoverDialogModel(migration!); + await cutoverDialogModel.fetchStatus(); + await cutoverDialogModel.cancelMigration(); + } + }); + } else { + await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CANCEL); + } + } catch (e) { + console.log(e); + } + }); + } + + private async populateMigrationTable(): Promise { + try { + const migrations = filterMigrations( + this._model._migrations, + (this._statusDropdown.value).name, + this._searchBox.value!); migrations.sort((m1, m2) => { return new Date(m1.migrationContext.properties.startedOn) > new Date(m2.migrationContext.properties.startedOn) ? -1 : 1; }); - migrations.forEach((migration, index) => { - const migrationRow: azdata.DeclarativeTableCellValue[] = []; - - const databaseHyperLink = this._view.modelBuilder.hyperlink().withProps({ - label: migration.migrationContext.properties.sourceDatabaseName, - url: '' - }).component(); - databaseHyperLink.onDidClick(async (e) => { - await (new MigrationCutoverDialog(migration)).initialize(); - }); - migrationRow.push({ - value: databaseHyperLink, - }); - - migrationRow.push({ - value: (migration.targetManagedInstance.type === 'microsoft.sql/managedinstances') ? loc.SQL_MANAGED_INSTANCE : loc.SQL_VIRTUAL_MACHINE - }); - - const sqlMigrationName = this._view.modelBuilder.hyperlink().withProps({ - label: migration.targetManagedInstance.name, - url: '' - }).component(); - sqlMigrationName.onDidClick((e) => { - vscode.window.showInformationMessage(loc.COMING_SOON); - }); - - migrationRow.push({ - value: sqlMigrationName - }); - - const dms = this._view.modelBuilder.hyperlink().withProps({ - label: migration.controller.name, - url: '' - }).component(); - dms.onDidClick((e) => { - (new SqlMigrationServiceDetailsDialog(migration)).initialize(); - }); - - migrationRow.push({ - value: dms - }); - - migrationRow.push({ - value: loc.ONLINE - }); - - let migrationStatus = migration.migrationContext.properties.migrationStatus ? migration.migrationContext.properties.migrationStatus : migration.migrationContext.properties.provisioningState; - - let warningCount = 0; - - if (migration.asyncOperationResult?.error?.message) { - warningCount++; - } - if (migration.migrationContext.properties.migrationFailureError?.message) { - warningCount++; - } - if (migration.migrationContext.properties.migrationStatusDetails?.fileUploadBlockingErrors) { - warningCount += migration.migrationContext.properties.migrationStatusDetails?.fileUploadBlockingErrors.length; - } - if (migration.migrationContext.properties.migrationStatusDetails?.restoreBlockingReason) { - warningCount++; - } - - migrationRow.push({ - value: loc.STATUS_WARNING_COUNT(migrationStatus, warningCount) - }); - - let duration; - if (migration.migrationContext.properties.endedOn) { - duration = convertTimeDifferenceToDuration(new Date(migration.migrationContext.properties.startedOn), new Date(migration.migrationContext.properties.endedOn)); - } else { - duration = convertTimeDifferenceToDuration(new Date(migration.migrationContext.properties.startedOn), new Date()); - } - - migrationRow.push({ - value: (migration.migrationContext.properties.startedOn) ? duration : '---' - }); - - migrationRow.push({ - value: (migration.migrationContext.properties.startedOn) ? new Date(migration.migrationContext.properties.startedOn).toLocaleString() : '---' - }); - migrationRow.push({ - value: (migration.migrationContext.properties.endedOn) ? new Date(migration.migrationContext.properties.endedOn).toLocaleString() : '---' - }); - - data.push(migrationRow); + const data: azdata.DeclarativeTableCellValue[][] = migrations.map((migration, index) => { + return [ + { value: this._getDatabaserHyperLink(migration) }, + { value: this._getMigrationStatus(migration) }, + { value: loc.ONLINE }, + { value: this._getMigrationTargetType(migration) }, + { value: migration.targetManagedInstance.name }, + { value: migration.controller.name }, + { + value: this._getMigrationDuration( + migration.migrationContext.properties.startedOn, + migration.migrationContext.properties.endedOn) + }, + { value: this._getMigrationTime(migration.migrationContext.properties.startedOn) }, + { value: this._getMigrationTime(migration.migrationContext.properties.endedOn) }, + { + value: { + commands: [ + 'sqlmigration.cutover', + 'sqlmigration.view.database', + 'sqlmigration.view.target', + 'sqlmigration.view.service', + 'sqlmigration.copy.migration', + 'sqlmigration.cancel.migration', + ], + context: migration.migrationContext.id + }, + } + ]; }); - this._statusTable.dataValues = data; + await this._statusTable.setDataValues(data); } catch (e) { console.log(e); } } + private _getDatabaserHyperLink(migration: MigrationContext): azdata.FlexContainer { + const imageControl = this._view.modelBuilder.image() + .withProps({ + iconPath: IconPathHelper.sqlDatabaseLogo, + iconHeight: statusImageSize, + iconWidth: statusImageSize, + height: statusImageSize, + width: statusImageSize, + CSSStyles: imageCellStyles + }) + .component(); + + const databaseHyperLink = this._view.modelBuilder + .hyperlink() + .withProps({ + label: migration.migrationContext.properties.sourceDatabaseName, + url: '', + CSSStyles: statusCellStyles + }).component(); + + databaseHyperLink.onDidClick( + async (e) => await (new MigrationCutoverDialog(migration)).initialize()); + + return this._view.modelBuilder + .flexContainer() + .withItems([imageControl, databaseHyperLink]) + .withProps({ CSSStyles: statusCellStyles, display: 'inline-flex' }) + .component(); + } + + private _getMigrationTime(migrationTime: string): string { + return migrationTime + ? new Date(migrationTime).toLocaleString() + : '---'; + } + + private _getMigrationDuration(startDate: string, endDate: string): string { + if (startDate) { + if (endDate) { + return convertTimeDifferenceToDuration( + new Date(startDate), + new Date(endDate)); + } else { + return convertTimeDifferenceToDuration( + new Date(startDate), + new Date()); + } + } + + return '---'; + } + + private _getMigrationTargetType(migration: MigrationContext): string { + return migration.targetManagedInstance.type === 'microsoft.sql/managedinstances' + ? loc.SQL_MANAGED_INSTANCE + : loc.SQL_VIRTUAL_MACHINE; + } + + private _getMigrationStatus(migration: MigrationContext): azdata.FlexContainer { + const properties = migration.migrationContext.properties; + const migrationStatus = properties.migrationStatus ?? properties.provisioningState; + let warningCount = 0; + if (migration.asyncOperationResult?.error?.message) { + warningCount++; + } + if (properties.migrationFailureError?.message) { + warningCount++; + } + if (properties.migrationStatusDetails?.fileUploadBlockingErrors) { + warningCount += properties.migrationStatusDetails?.fileUploadBlockingErrors.length; + } + if (properties.migrationStatusDetails?.restoreBlockingReason) { + warningCount++; + } + + return this._getStatusControl(migrationStatus, warningCount); + } + + private _getStatusControl(status: string, count: number): azdata.FlexContainer { + const control = this._view.modelBuilder + .flexContainer() + .withItems([ + // migration status icon + this._view.modelBuilder.image() + .withProps({ + iconPath: this._statusImageMap(status), + iconHeight: statusImageSize, + iconWidth: statusImageSize, + height: statusImageSize, + width: statusImageSize, + CSSStyles: imageCellStyles + }) + .component(), + // migration status text + this._view.modelBuilder.text().withProps({ + value: loc.STATUS_VALUE(status, count), + height: statusImageSize, + CSSStyles: statusCellStyles, + }).component() + ]) + .withProps({ CSSStyles: statusCellStyles, display: 'inline-flex' }) + .component(); + + if (count > 0) { + control.addItems([ + // migration warning / error image + this._view.modelBuilder.image().withProps({ + iconPath: this._statusInfoMap(status), + iconHeight: statusImageSize, + iconWidth: statusImageSize, + height: statusImageSize, + width: statusImageSize, + CSSStyles: imageCellStyles + }).component(), + // migration warning / error counts + this._view.modelBuilder.text().withProps({ + value: loc.STATUS_WARNING_COUNT(status, count), + height: statusImageSize, + CSSStyles: statusCellStyles, + }).component() + ]); + } + + return control; + } + private async refreshTable(): Promise { this._refreshLoader.loading = true; const currentConnection = await azdata.connection.getCurrentConnection(); this._model._migrations = await MigrationLocalStorage.getMigrationsBySourceConnections(currentConnection, true); - this.populateMigrationTable(); + await this.populateMigrationTable(); this._refreshLoader.loading = false; } @@ -298,25 +477,9 @@ export class MigrationStatusDialog { headerCssStyles: headerCssStyles }, { - displayName: loc.AZURE_SQL_TARGET, - valueType: azdata.DeclarativeDataType.string, - width: '140px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.TARGET_AZURE_SQL_INSTANCE_NAME, + displayName: loc.MIGRATION_STATUS, valueType: azdata.DeclarativeDataType.component, - width: '130px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.DATABASE_MIGRATION_SERVICE, - valueType: azdata.DeclarativeDataType.component, - width: '150px', + width: '170px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles @@ -324,13 +487,29 @@ export class MigrationStatusDialog { { displayName: loc.MIGRATION_MODE, valueType: azdata.DeclarativeDataType.string, - width: '100px', + width: '90px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { - displayName: loc.MIGRATION_STATUS, + displayName: loc.AZURE_SQL_TARGET, + valueType: azdata.DeclarativeDataType.string, + width: '130px', + isReadOnly: true, + rowCssStyles: rowCssStyle, + headerCssStyles: headerCssStyles + }, + { + displayName: loc.TARGET_AZURE_SQL_INSTANCE_NAME, + valueType: azdata.DeclarativeDataType.string, + width: '130px', + isReadOnly: true, + rowCssStyles: rowCssStyle, + headerCssStyles: headerCssStyles + }, + { + displayName: loc.DATABASE_MIGRATION_SERVICE, valueType: azdata.DeclarativeDataType.string, width: '150px', isReadOnly: true, @@ -348,7 +527,7 @@ export class MigrationStatusDialog { { displayName: loc.START_TIME, valueType: azdata.DeclarativeDataType.string, - width: '120px', + width: '140px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles @@ -356,13 +535,50 @@ export class MigrationStatusDialog { { displayName: loc.FINISH_TIME, valueType: azdata.DeclarativeDataType.string, - width: '120px', + width: '140px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles + }, + { + displayName: '', + valueType: azdata.DeclarativeDataType.menu, + width: '20px', + isReadOnly: true, + rowCssStyles: rowCssStyle, + headerCssStyles: headerCssStyles, } ] }).component(); return this._statusTable; } + + private _statusImageMap(status: string): azdata.IconPath { + switch (status) { + case 'InProgress': + return IconPathHelper.inProgressMigration; + case 'Succeeded': + return IconPathHelper.completedMigration; + case 'Creating': + return IconPathHelper.notStartedMigration; + case 'Completing': + return IconPathHelper.completingCutover; + case 'Cancelling': + return IconPathHelper.cancel; + case 'Failed': + default: + return IconPathHelper.error; + } + } + + private _statusInfoMap(status: string): azdata.IconPath { + switch (status) { + case 'InProgress': + case 'Creating': + case 'Completing': + return IconPathHelper.warning; + default: + return IconPathHelper.error; + } + } } diff --git a/extensions/sql-migration/src/wizard/accountsSelectionPage.ts b/extensions/sql-migration/src/wizard/accountsSelectionPage.ts index 033263eb9d..465ea52c62 100644 --- a/extensions/sql-migration/src/wizard/accountsSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/accountsSelectionPage.ts @@ -10,6 +10,7 @@ import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; import * as constants from '../constants/strings'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; import { deepClone, findDropDownItemIndex, selectDropDownIndex } from '../api/utils'; +import { getSubscriptions } from '../api/azure'; export class AccountsSelectionPage extends MigrationWizardPage { private _azureAccountsDropdown!: azdata.DropDownComponent; @@ -198,14 +199,21 @@ export class AccountsSelectionPage extends MigrationWizardPage { } public async onPageEnter(): Promise { - this.wizard.registerNavigationValidator(pageChangeInfo => { - if (this.migrationStateModel._azureAccount?.isStale === true) { - this.wizard.message = { - text: constants.ACCOUNT_STALE_ERROR(this.migrationStateModel._azureAccount) - }; - return false; + this.wizard.registerNavigationValidator(async pageChangeInfo => { + try { + if (!this.migrationStateModel._azureAccount?.isStale) { + const subscriptions = await getSubscriptions(this.migrationStateModel._azureAccount); + if (subscriptions?.length > 0) { + return true; + } + } + + this.wizard.message = { text: constants.ACCOUNT_STALE_ERROR(this.migrationStateModel._azureAccount) }; + } catch (error) { + this.wizard.message = { text: constants.ACCOUNT_ACCESS_ERROR(this.migrationStateModel._azureAccount, error) }; } - return true; + + return false; }); }