/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { IconPathHelper } from '../../constants/iconPathHelper'; import { MigrationContext, MigrationLocalStorage, MigrationStatus } from '../../models/migrationLocalStorage'; import { MigrationCutoverDialog } from '../migrationCutover/migrationCutoverDialog'; import { AdsMigrationStatus, MigrationStatusDialogModel } from './migrationStatusDialogModel'; import * as loc from '../../constants/strings'; import { clearDialogMessage, convertTimeDifferenceToDuration, filterMigrations, getMigrationStatusImage, SupportedAutoRefreshIntervals } from '../../api/utils'; import { SqlMigrationServiceDetailsDialog } from '../sqlMigrationService/sqlMigrationServiceDetailsDialog'; import { ConfirmCutoverDialog } from '../migrationCutover/confirmCutoverDialog'; import { MigrationCutoverDialogModel } from '../migrationCutover/migrationCutoverDialogModel'; import { getMigrationTargetType, getMigrationMode } from '../../constants/helper'; 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' }; const MenuCommands = { Cutover: 'sqlmigration.cutover', ViewDatabase: 'sqlmigration.view.database', ViewTarget: 'sqlmigration.view.target', ViewService: 'sqlmigration.view.service', CopyMigration: 'sqlmigration.copy.migration', CancelMigration: 'sqlmigration.cancel.migration', }; export class MigrationStatusDialog { private _model: MigrationStatusDialogModel; private _dialogObject!: azdata.window.Dialog; private _view!: azdata.ModelView; private _searchBox!: azdata.InputBoxComponent; private _refresh!: azdata.ButtonComponent; private _statusDropdown!: azdata.DropDownComponent; private _statusTable!: azdata.DeclarativeTableComponent; private _refreshLoader!: azdata.LoadingComponent; private _autoRefreshHandle!: NodeJS.Timeout; private _disposables: vscode.Disposable[] = []; private isRefreshing = false; constructor(migrations: MigrationContext[], private _filter: AdsMigrationStatus) { this._model = new MigrationStatusDialogModel(migrations); this._dialogObject = azdata.window.createModelViewDialog(loc.MIGRATION_STATUS, 'MigrationControllerDialog', 'wide'); } initialize() { let tab = azdata.window.createTab(''); tab.registerContent(async (view: azdata.ModelView) => { this._view = view; this.registerCommands(); const formBuilder = view.modelBuilder.formContainer().withFormItems( [ { component: this.createSearchAndRefreshContainer() }, { component: this.createStatusTable() } ], { horizontal: false } ); const form = formBuilder.withLayout({ width: '100%' }).component(); this._disposables.push(this._view.onClosed(e => { clearInterval(this._autoRefreshHandle); this._disposables.forEach( d => { try { d.dispose(); } catch { } }); })); return view.initializeModel(form); }); this._dialogObject.content = [tab]; this._dialogObject.cancelButton.hidden = true; this._dialogObject.okButton.label = loc.CLOSE; this._disposables.push(this._dialogObject.okButton.onClick(e => { clearInterval(this._autoRefreshHandle); })); azdata.window.openDialog(this._dialogObject); } private canCancelMigration = (status: string | undefined) => status && ( status === MigrationStatus.InProgress || status === MigrationStatus.Creating || status === MigrationStatus.Completing || status === MigrationStatus.Canceling ); private canCutoverMigration = (status: string | undefined) => status === MigrationStatus.InProgress; private createSearchAndRefreshContainer(): azdata.FlexContainer { this._searchBox = this._view.modelBuilder.inputBox().withProps({ stopEnterPropagation: true, placeHolder: loc.SEARCH_FOR_MIGRATIONS, width: '360px' }).component(); this._disposables.push(this._searchBox.onTextChanged((value) => { this.populateMigrationTable(); })); this._refresh = this._view.modelBuilder.button().withProps({ iconPath: IconPathHelper.refresh, iconHeight: '16px', iconWidth: '20px', height: '30px', label: loc.REFRESH_BUTTON_LABEL, }).component(); this._disposables.push( this._refresh.onDidClick( async (e) => { await this.refreshTable(); })); const flexContainer = this._view.modelBuilder.flexContainer().withProps({ width: 900, CSSStyles: { 'justify-content': 'left' }, }).component(); flexContainer.addItem(this._searchBox, { flex: '0' }); this._statusDropdown = this._view.modelBuilder.dropDown().withProps({ ariaLabel: loc.MIGRATION_STATUS_FILTER, values: this._model.statusDropdownValues, width: '220px' }).component(); this._disposables.push(this._statusDropdown.onValueChanged((value) => { this.populateMigrationTable(); })); if (this._filter) { this._statusDropdown.value = (this._statusDropdown.values).find((value) => { return value.name === this._filter; }); } flexContainer.addItem(this._statusDropdown, { flex: '0', CSSStyles: { 'margin-left': '20px' } }); flexContainer.addItem(this._refresh, { flex: '0', CSSStyles: { 'margin-left': '20px' } }); this._refreshLoader = this._view.modelBuilder.loadingComponent().withProps({ loading: false, height: '55px' }).component(); flexContainer.addItem(this._refreshLoader, { flex: '0 0 auto', CSSStyles: { '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; } private setAutoRefresh(interval: SupportedAutoRefreshIntervals): void { const classVariable = this; clearInterval(this._autoRefreshHandle); if (interval !== -1) { this._autoRefreshHandle = setInterval(async function () { await classVariable.refreshTable(); }, interval); } } private registerCommands(): void { this._disposables.push(vscode.commands.registerCommand( MenuCommands.Cutover, async (migrationId: string) => { try { clearDialogMessage(this._dialogObject); 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) { this._dialogObject.message = { text: loc.MIGRATION_STATUS_REFRESH_ERROR, description: e.message, level: azdata.window.MessageLevel.Error }; console.log(e); } })); this._disposables.push(vscode.commands.registerCommand( MenuCommands.ViewDatabase, 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); } })); this._disposables.push(vscode.commands.registerCommand( MenuCommands.ViewTarget, 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); } })); this._disposables.push(vscode.commands.registerCommand( MenuCommands.ViewService, 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); } })); this._disposables.push(vscode.commands.registerCommand( MenuCommands.CopyMigration, async (migrationId: string) => { try { clearDialogMessage(this._dialogObject); 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) { this._dialogObject.message = { text: loc.MIGRATION_STATUS_REFRESH_ERROR, description: e.message, level: azdata.window.MessageLevel.Error }; console.log(e); } })); this._disposables.push(vscode.commands.registerCommand( MenuCommands.CancelMigration, async (migrationId: string) => { try { clearDialogMessage(this._dialogObject); 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) { this._dialogObject.message = { text: loc.MIGRATION_CANCELLATION_ERROR, description: e.message, level: azdata.window.MessageLevel.Error }; 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; }); const data: azdata.DeclarativeTableCellValue[][] = migrations.map((migration, index) => { return [ { value: this._getDatabaserHyperLink(migration) }, { value: this._getMigrationStatus(migration) }, { value: getMigrationMode(migration) }, { value: 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: this._getMenuCommands(migration), context: migration.migrationContext.id }, } ]; }); 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(); this._disposables.push(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 _getMenuCommands(migration: MigrationContext): string[] { const menuCommands: string[] = []; const migrationStatus = migration?.migrationContext?.properties?.migrationStatus; if (getMigrationMode(migration) === loc.ONLINE && this.canCutoverMigration(migrationStatus)) { menuCommands.push(MenuCommands.Cutover); } menuCommands.push(...[ MenuCommands.ViewDatabase, MenuCommands.ViewTarget, MenuCommands.ViewService, MenuCommands.CopyMigration]); if (this.canCancelMigration(migrationStatus)) { menuCommands.push(MenuCommands.CancelMigration); } return menuCommands; } 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: getMigrationStatusImage(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 { if (this.isRefreshing) { return; } this.isRefreshing = true; try { clearDialogMessage(this._dialogObject); this._refreshLoader.loading = true; const currentConnection = await azdata.connection.getCurrentConnection(); this._model._migrations = await MigrationLocalStorage.getMigrationsBySourceConnections(currentConnection, true); await this.populateMigrationTable(); } catch (e) { this._dialogObject.message = { text: loc.MIGRATION_STATUS_REFRESH_ERROR, description: e.message, level: azdata.window.MessageLevel.Error }; console.log(e); } finally { this.isRefreshing = false; this._refreshLoader.loading = false; } } private createStatusTable(): azdata.DeclarativeTableComponent { const rowCssStyle: azdata.CssStyles = { 'border': 'none', 'text-align': 'left', 'border-bottom': '1px solid', }; const headerCssStyles: azdata.CssStyles = { 'border': 'none', 'text-align': 'left', 'border-bottom': '1px solid', 'font-weight': 'bold', 'padding-left': '0px', 'padding-right': '0px' }; this._statusTable = this._view.modelBuilder.declarativeTable().withProps({ ariaLabel: loc.MIGRATION_STATUS, columns: [ { displayName: loc.DATABASE, valueType: azdata.DeclarativeDataType.component, width: '90px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { displayName: loc.MIGRATION_STATUS, valueType: azdata.DeclarativeDataType.component, width: '170px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { displayName: loc.MIGRATION_MODE, valueType: azdata.DeclarativeDataType.string, width: '90px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { 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, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { displayName: loc.DURATION, valueType: azdata.DeclarativeDataType.string, width: '55px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { displayName: loc.START_TIME, valueType: azdata.DeclarativeDataType.string, width: '140px', isReadOnly: true, rowCssStyles: rowCssStyle, headerCssStyles: headerCssStyles }, { displayName: loc.FINISH_TIME, valueType: azdata.DeclarativeDataType.string, 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 _statusInfoMap(status: string): azdata.IconPath { return status === MigrationStatus.InProgress || status === MigrationStatus.Creating || status === MigrationStatus.Completing ? IconPathHelper.warning : IconPathHelper.error; } }