Add SQL DB offline migration wizard experience (#20403)

* sql db wizard with target selection

* add database table selection

* add sqldb to service and IR page

* Code complete

* navigation bug fixes

* fix target db selection

* improve sqldb error and status reporting

* fix error count bug

* remove table status inference

* address review feedback

* update resource strings and content

* fix migraton status string, use localized value

* fix ux navigation issues

* fix back/fwd w/o changes from changing data
This commit is contained in:
brian-harris
2022-08-19 18:12:34 -07:00
committed by GitHub
parent c0b09dcedd
commit 7a736b76fa
42 changed files with 5716 additions and 4209 deletions

View File

@@ -5,82 +5,121 @@
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as mssql from 'mssql';
import { promises as fs } from 'fs';
import { DatabaseMigration, getMigrationDetails } from '../api/azure';
import { MenuCommands, SqlMigrationExtensionId } from '../api/utils';
import { canCancelMigration, canRetryMigration } from '../constants/helper';
import { IconPathHelper } from '../constants/iconPathHelper';
import { MigrationNotebookInfo, NotebookPathHelper } from '../constants/notebookPathHelper';
import * as loc from '../constants/strings';
import { SavedAssessmentDialog } from '../dialog/assessmentResults/savedAssessmentDialog';
import { ConfirmCutoverDialog } from '../dialog/migrationCutover/confirmCutoverDialog';
import { MigrationCutoverDialogModel } from '../dialog/migrationCutover/migrationCutoverDialogModel';
import { RetryMigrationDialog } from '../dialog/retryMigration/retryMigrationDialog';
import { SqlMigrationServiceDetailsDialog } from '../dialog/sqlMigrationService/sqlMigrationServiceDetailsDialog';
import { MigrationLocalStorage } from '../models/migrationLocalStorage';
import { MigrationStateModel, SavedInfo } from '../models/stateMachine';
import { logError, TelemetryViews } from '../telemtery';
import { WizardController } from '../wizard/wizardController';
import { DashboardStatusBar, ErrorEvent } from './DashboardStatusBar';
import { DashboardTab } from './dashboardTab';
import { MigrationsTab, MigrationsTabId } from './migrationsTab';
import { AdsMigrationStatus } from './tabBase';
import { AdsMigrationStatus, MigrationDetailsEvent, ServiceContextChangeEvent } from './tabBase';
export interface DashboardStatusBar {
showError: (errorTitle: string, errorLable: string, errorDescription: string) => Promise<void>;
clearError: () => Promise<void>;
errorTitle: string;
errorLabel: string;
errorDescription: string;
export interface MenuCommandArgs {
connectionId: string,
migrationId: string,
migrationOperationId: string,
}
export class DashboardWidget implements DashboardStatusBar {
private _context: vscode.ExtensionContext;
private _view!: azdata.ModelView;
private _tabs!: azdata.TabbedPanelComponent;
private _statusInfoBox!: azdata.InfoBoxComponent;
private _dashboardTab!: DashboardTab;
private _migrationsTab!: MigrationsTab;
private _disposables: vscode.Disposable[] = [];
export class DashboardWidget {
public stateModel!: MigrationStateModel;
private readonly _context: vscode.ExtensionContext;
private readonly _onServiceContextChanged: vscode.EventEmitter<ServiceContextChangeEvent>;
private readonly _migrationDetailsEvent: vscode.EventEmitter<MigrationDetailsEvent>;
private readonly _errorEvent: vscode.EventEmitter<ErrorEvent>;
constructor(context: vscode.ExtensionContext) {
this._context = context;
NotebookPathHelper.setExtensionContext(context);
IconPathHelper.setExtensionContext(context);
MigrationLocalStorage.setExtensionContext(context);
this._onServiceContextChanged = new vscode.EventEmitter<ServiceContextChangeEvent>();
this._errorEvent = new vscode.EventEmitter<ErrorEvent>();
this._migrationDetailsEvent = new vscode.EventEmitter<MigrationDetailsEvent>();
context.subscriptions.push(this._onServiceContextChanged);
context.subscriptions.push(this._errorEvent);
context.subscriptions.push(this._migrationDetailsEvent);
}
public errorTitle: string = '';
public errorLabel: string = '';
public errorDescription: string = '';
public async register(): Promise<void> {
await this._registerCommands();
public async showError(errorTitle: string, errorLabel: string, errorDescription: string): Promise<void> {
this.errorTitle = errorTitle;
this.errorLabel = errorLabel;
this.errorDescription = errorDescription;
this._statusInfoBox.style = 'error';
this._statusInfoBox.text = errorTitle;
await this._updateStatusDisplay(this._statusInfoBox, true);
}
public async clearError(): Promise<void> {
await this._updateStatusDisplay(this._statusInfoBox, false);
this.errorTitle = '';
this.errorLabel = '';
this.errorDescription = '';
this._statusInfoBox.style = 'success';
this._statusInfoBox.text = '';
}
public register(): void {
azdata.ui.registerModelViewProvider('migration-dashboard', async (view) => {
this._view = view;
this._disposables.push(
this._view.onClosed(e => {
this._disposables.forEach(
d => { try { d.dispose(); } catch { } });
}));
const disposables: vscode.Disposable[] = [];
const _view = view;
const statusInfoBox = view.modelBuilder.infoBox()
.withProps({
style: 'error',
text: '',
clickableButtonAriaLabel: loc.ERROR_DIALOG_ARIA_CLICK_VIEW_ERROR_DETAILS,
announceText: true,
isClickable: true,
display: 'none',
CSSStyles: { 'font-size': '14px', 'display': 'none', },
}).component();
const connectionProfile = await azdata.connection.getCurrentConnection();
const statusBar = new DashboardStatusBar(
this._context,
connectionProfile.connectionId,
statusInfoBox,
this._errorEvent);
disposables.push(
statusInfoBox.onDidClick(
async e => await statusBar.openErrorDialog()));
disposables.push(
_view.onClosed(e =>
disposables.forEach(
d => { try { d.dispose(); } catch { } })));
const openMigrationFcn = async (filter: AdsMigrationStatus): Promise<void> => {
this._tabs.selectTab(MigrationsTabId);
await this._migrationsTab.setMigrationFilter(filter);
if (!migrationsTabInitialized) {
migrationsTabInitialized = true;
tabs.selectTab(MigrationsTabId);
await migrationsTab.setMigrationFilter(AdsMigrationStatus.ALL);
await migrationsTab.refresh();
await migrationsTab.setMigrationFilter(filter);
} else {
const promise = migrationsTab.setMigrationFilter(filter);
tabs.selectTab(MigrationsTabId);
await promise;
}
};
this._dashboardTab = await new DashboardTab().create(
const dashboardTab = await new DashboardTab().create(
view,
async (filter: AdsMigrationStatus) => await openMigrationFcn(filter),
this);
this._disposables.push(this._dashboardTab);
this._onServiceContextChanged,
statusBar);
disposables.push(dashboardTab);
this._migrationsTab = await new MigrationsTab().create(
const migrationsTab = await new MigrationsTab().create(
this._context,
view,
this);
this._disposables.push(this._migrationsTab);
this._onServiceContextChanged,
this._migrationDetailsEvent,
statusBar);
disposables.push(migrationsTab);
this._tabs = view.modelBuilder.tabbedPanel()
.withTabs([this._dashboardTab, this._migrationsTab])
const tabs = view.modelBuilder.tabbedPanel()
.withTabs([dashboardTab, migrationsTab])
.withLayout({ alwaysShowTabs: true, orientation: azdata.TabOrientation.Horizontal })
.withProps({
CSSStyles: {
@@ -91,107 +130,338 @@ export class DashboardWidget implements DashboardStatusBar {
})
.component();
this._disposables.push(
this._tabs.onTabChanged(
async id => {
await this.clearError();
await this.onDialogClosed();
}));
this._statusInfoBox = view.modelBuilder.infoBox()
.withProps({
style: 'error',
text: '',
announceText: true,
isClickable: true,
display: 'none',
CSSStyles: { 'font-size': '14px' },
}).component();
this._disposables.push(
this._statusInfoBox.onDidClick(
async e => await this.openErrorDialog()));
let migrationsTabInitialized = false;
disposables.push(
tabs.onTabChanged(async tabId => {
const connectionProfile = await azdata.connection.getCurrentConnection();
await this.clearError(connectionProfile.connectionId);
if (tabId === MigrationsTabId && !migrationsTabInitialized) {
migrationsTabInitialized = true;
await migrationsTab.refresh();
}
}));
const flexContainer = view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.withItems([this._statusInfoBox, this._tabs])
.withItems([statusInfoBox, tabs])
.component();
await view.initializeModel(flexContainer);
await this.refresh();
await dashboardTab.refresh();
});
}
public async refresh(): Promise<void> {
void this._migrationsTab.refresh();
await this._dashboardTab.refresh();
}
private async _registerCommands(): Promise<void> {
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.Cutover,
async (args: MenuCommandArgs) => {
try {
await this.clearError(args.connectionId);
const migration = await this._getMigrationById(args.migrationId, args.migrationOperationId);
if (canRetryMigration(migration)) {
const cutoverDialogModel = new MigrationCutoverDialogModel(
await MigrationLocalStorage.getMigrationServiceContext(),
migration!);
await cutoverDialogModel.fetchStatus();
const dialog = new ConfirmCutoverDialog(cutoverDialogModel);
await dialog.initialize();
if (cutoverDialogModel.CutoverError) {
void vscode.window.showErrorMessage(loc.MIGRATION_CUTOVER_ERROR);
logError(TelemetryViews.MigrationsTab, MenuCommands.Cutover, cutoverDialogModel.CutoverError);
}
} else {
await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CUTOVER);
}
} catch (e) {
await this.showError(
args.connectionId,
loc.MIGRATION_CUTOVER_ERROR,
loc.MIGRATION_CUTOVER_ERROR,
e.message);
public async onDialogClosed(): Promise<void> {
await this._dashboardTab.onDialogClosed();
await this._migrationsTab.onDialogClosed();
}
private _errorDialogIsOpen: boolean = false;
protected async openErrorDialog(): Promise<void> {
if (this._errorDialogIsOpen) {
return;
}
try {
const tab = azdata.window.createTab(this.errorTitle);
tab.registerContent(async (view) => {
const flex = view.modelBuilder.flexContainer()
.withItems([
view.modelBuilder.text()
.withProps({ value: this.errorLabel, CSSStyles: { 'margin': '0px 0px 5px 5px' } })
.component(),
view.modelBuilder.inputBox()
.withProps({
value: this.errorDescription,
readOnly: true,
multiline: true,
inputType: 'text',
rows: 20,
CSSStyles: { 'overflow': 'hidden auto', 'margin': '0px 0px 0px 5px' },
})
.component()
])
.withLayout({
flexFlow: 'column',
width: 420,
})
.withProps({ CSSStyles: { 'margin': '0 10px 0 10px' } })
.component();
await view.initializeModel(flex);
});
const dialog = azdata.window.createModelViewDialog(
this.errorTitle,
'errorDialog',
450,
'flyout');
dialog.content = [tab];
dialog.okButton.label = loc.ERROR_DIALOG_CLEAR_BUTTON_LABEL;
dialog.okButton.focused = true;
dialog.cancelButton.label = loc.CLOSE;
this._disposables.push(
dialog.onClosed(async e => {
if (e === 'ok') {
await this.clearError();
logError(TelemetryViews.MigrationsTab, MenuCommands.Cutover, e);
}
this._errorDialogIsOpen = false;
}));
azdata.window.openDialog(dialog);
} catch (error) {
this._errorDialogIsOpen = false;
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.ViewDatabase,
async (args: MenuCommandArgs) => {
try {
await this.clearError(args.connectionId);
this._migrationDetailsEvent.fire({
connectionId: args.connectionId,
migrationId: args.migrationId,
migrationOperationId: args.migrationOperationId,
});
} catch (e) {
await this.showError(
args.connectionId,
loc.OPEN_MIGRATION_DETAILS_ERROR,
loc.OPEN_MIGRATION_DETAILS_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.ViewDatabase, e);
}
}));
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.ViewTarget,
async (args: MenuCommandArgs) => {
try {
const migration = await this._getMigrationById(args.migrationId, args.migrationOperationId);
const url = 'https://portal.azure.com/#resource/' + migration!.properties.scope;
await vscode.env.openExternal(vscode.Uri.parse(url));
} catch (e) {
await this.showError(
args.connectionId,
loc.OPEN_MIGRATION_TARGET_ERROR,
loc.OPEN_MIGRATION_TARGET_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.ViewTarget, e);
}
}));
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.ViewService,
async (args: MenuCommandArgs) => {
try {
await this.clearError(args.connectionId);
const migration = await this._getMigrationById(args.migrationId, args.migrationOperationId);
const dialog = new SqlMigrationServiceDetailsDialog(
await MigrationLocalStorage.getMigrationServiceContext(),
migration!);
await dialog.initialize();
} catch (e) {
await this.showError(
args.connectionId,
loc.OPEN_MIGRATION_SERVICE_ERROR,
loc.OPEN_MIGRATION_SERVICE_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.ViewService, e);
}
}));
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.CopyMigration,
async (args: MenuCommandArgs) => {
await this.clearError(args.connectionId);
const migration = await this._getMigrationById(args.migrationId, args.migrationOperationId);
if (migration) {
const cutoverDialogModel = new MigrationCutoverDialogModel(
await MigrationLocalStorage.getMigrationServiceContext(),
migration);
try {
await cutoverDialogModel.fetchStatus();
} catch (e) {
await this.showError(
args.connectionId,
loc.MIGRATION_STATUS_REFRESH_ERROR,
loc.MIGRATION_STATUS_REFRESH_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.CopyMigration, e);
}
await vscode.env.clipboard.writeText(JSON.stringify(cutoverDialogModel.migration, undefined, 2));
await vscode.window.showInformationMessage(loc.DETAILS_COPIED);
}
}));
this._context.subscriptions.push(vscode.commands.registerCommand(
MenuCommands.CancelMigration,
async (args: MenuCommandArgs) => {
try {
await this.clearError(args.connectionId);
const migration = await this._getMigrationById(args.migrationId, args.migrationOperationId);
if (canCancelMigration(migration)) {
void vscode.window.showInformationMessage(loc.CANCEL_MIGRATION_CONFIRMATION, loc.YES, loc.NO)
.then(async (v) => {
if (v === loc.YES) {
const cutoverDialogModel = new MigrationCutoverDialogModel(
await MigrationLocalStorage.getMigrationServiceContext(),
migration!);
await cutoverDialogModel.fetchStatus();
await cutoverDialogModel.cancelMigration();
if (cutoverDialogModel.CancelMigrationError) {
void vscode.window.showErrorMessage(loc.MIGRATION_CANNOT_CANCEL);
logError(TelemetryViews.MigrationsTab, MenuCommands.CancelMigration, cutoverDialogModel.CancelMigrationError);
}
}
});
} else {
await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CANCEL);
}
} catch (e) {
await this.showError(
args.connectionId,
loc.MIGRATION_CANCELLATION_ERROR,
loc.MIGRATION_CANCELLATION_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.CancelMigration, e);
}
}));
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.RetryMigration,
async (args: MenuCommandArgs) => {
try {
await this.clearError(args.connectionId);
const migration = await this._getMigrationById(args.migrationId, args.migrationOperationId);
if (canRetryMigration(migration)) {
const retryMigrationDialog = new RetryMigrationDialog(
this._context,
await MigrationLocalStorage.getMigrationServiceContext(),
migration!,
this._onServiceContextChanged);
await retryMigrationDialog.openDialog();
}
else {
await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_RETRY);
}
} catch (e) {
await this.showError(
args.connectionId,
loc.MIGRATION_RETRY_ERROR,
loc.MIGRATION_RETRY_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.RetryMigration, e);
}
}));
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.StartMigration,
async () => await this.launchMigrationWizard()));
this._context.subscriptions.push(
vscode.commands.registerCommand(
MenuCommands.OpenNotebooks,
async () => {
const input = vscode.window.createQuickPick<MigrationNotebookInfo>();
input.placeholder = loc.NOTEBOOK_QUICK_PICK_PLACEHOLDER;
input.items = NotebookPathHelper.getAllMigrationNotebooks();
this._context.subscriptions.push(
input.onDidAccept(async (e) => {
const selectedNotebook = input.selectedItems[0];
if (selectedNotebook) {
try {
await azdata.nb.showNotebookDocument(vscode.Uri.parse(`untitled: ${selectedNotebook.label}`), {
preview: false,
initialContent: (await fs.readFile(selectedNotebook.notebookPath)).toString(),
initialDirtyState: false
});
} catch (e) {
void vscode.window.showErrorMessage(`${loc.NOTEBOOK_OPEN_ERROR} - ${e.toString()}`);
}
input.hide();
}
}));
input.show();
}));
this._context.subscriptions.push(azdata.tasks.registerTask(
MenuCommands.StartMigration,
async () => await this.launchMigrationWizard()));
this._context.subscriptions.push(
azdata.tasks.registerTask(
MenuCommands.NewSupportRequest,
async () => await this.launchNewSupportRequest()));
this._context.subscriptions.push(
azdata.tasks.registerTask(
MenuCommands.SendFeedback,
async () => {
const actionId = MenuCommands.IssueReporter;
const args = {
extensionId: SqlMigrationExtensionId,
issueTitle: loc.FEEDBACK_ISSUE_TITLE,
};
return await vscode.commands.executeCommand(actionId, args);
}));
}
private async clearError(connectionId: string): Promise<void> {
this._errorEvent.fire({
connectionId: connectionId,
title: '',
label: '',
message: '',
});
}
private async showError(connectionId: string, title: string, label: string, message: string): Promise<void> {
this._errorEvent.fire({
connectionId: connectionId,
title: title,
label: label,
message: message,
});
}
private async _getMigrationById(migrationId: string, migrationOperationId: string): Promise<DatabaseMigration | undefined> {
const context = await MigrationLocalStorage.getMigrationServiceContext();
if (context.azureAccount && context.subscription) {
return getMigrationDetails(
context.azureAccount,
context.subscription,
migrationId,
migrationOperationId);
}
return undefined;
}
public async launchMigrationWizard(): Promise<void> {
const activeConnection = await azdata.connection.getCurrentConnection();
let connectionId: string = '';
let serverName: string = '';
if (!activeConnection) {
const connection = await azdata.connection.openConnectionDialog();
if (connection) {
connectionId = connection.connectionId;
serverName = connection.options.server;
}
} else {
connectionId = activeConnection.connectionId;
serverName = activeConnection.serverName;
}
if (serverName) {
const api = (await vscode.extensions.getExtension(mssql.extension.name)?.activate()) as mssql.IExtension;
if (api) {
this.stateModel = new MigrationStateModel(this._context, connectionId, api.sqlMigration);
this._context.subscriptions.push(this.stateModel);
const savedInfo = this.checkSavedInfo(serverName);
if (savedInfo) {
this.stateModel.savedInfo = savedInfo;
this.stateModel.serverName = serverName;
const savedAssessmentDialog = new SavedAssessmentDialog(
this._context,
this.stateModel,
this._onServiceContextChanged);
await savedAssessmentDialog.openDialog();
} else {
const wizardController = new WizardController(
this._context,
this.stateModel,
this._onServiceContextChanged);
await wizardController.openWizard(connectionId);
}
}
}
}
private async _updateStatusDisplay(control: azdata.Component, visible: boolean): Promise<void> {
await control.updateCssStyles({ 'display': visible ? 'inline' : 'none' });
private checkSavedInfo(serverName: string): SavedInfo | undefined {
return this._context.globalState.get<SavedInfo>(`${this.stateModel.mementoString}.${serverName}`);
}
public async launchNewSupportRequest(): Promise<void> {
await vscode.env.openExternal(vscode.Uri.parse(
`https://portal.azure.com/#blade/Microsoft_Azure_Support/HelpAndSupportBlade/newsupportrequest`));
}
}