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

@@ -6,20 +6,16 @@
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import { IconPathHelper } from '../constants/iconPathHelper';
import { getCurrentMigrations, getSelectedServiceStatus, MigrationLocalStorage } from '../models/migrationLocalStorage';
import { getCurrentMigrations, getSelectedServiceStatus } from '../models/migrationLocalStorage';
import * as loc from '../constants/strings';
import { filterMigrations, getMigrationDuration, getMigrationStatusImage, getMigrationStatusWithErrors, getMigrationTime } from '../api/utils';
import { SqlMigrationServiceDetailsDialog } from '../dialog/sqlMigrationService/sqlMigrationServiceDetailsDialog';
import { ConfirmCutoverDialog } from '../dialog/migrationCutover/confirmCutoverDialog';
import { MigrationCutoverDialogModel } from '../dialog/migrationCutover/migrationCutoverDialogModel';
import { getMigrationTargetType, getMigrationMode, canRetryMigration, getMigrationModeEnum, canCancelMigration, canCutoverMigration, getMigrationStatus } from '../constants/helper';
import { RetryMigrationDialog } from '../dialog/retryMigration/retryMigrationDialog';
import { filterMigrations, getMigrationDuration, getMigrationStatusImage, getMigrationStatusWithErrors, getMigrationTime, MenuCommands } from '../api/utils';
import { getMigrationTargetType, getMigrationMode, getMigrationModeEnum, canCancelMigration, canCutoverMigration, getMigrationStatus } from '../constants/helper';
import { DatabaseMigration, getResourceName } from '../api/azure';
import { logError, TelemetryViews } from '../telemtery';
import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog';
import { AdsMigrationStatus, EmptySettingValue, MenuCommands, TabBase } from './tabBase';
import { DashboardStatusBar } from './sqlServerDashboard';
import { AdsMigrationStatus, EmptySettingValue, ServiceContextChangeEvent, TabBase } from './tabBase';
import { MigrationMode } from '../models/stateMachine';
import { DashboardStatusBar } from './DashboardStatusBar';
export const MigrationsListTabId = 'MigrationsListTab';
@@ -58,12 +54,14 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
context: vscode.ExtensionContext,
view: azdata.ModelView,
openMigrationDetails: (migration: DatabaseMigration) => Promise<void>,
serviceContextChangedEvent: vscode.EventEmitter<ServiceContextChangeEvent>,
statusBar: DashboardStatusBar,
): Promise<MigrationsListTab> {
this.view = view;
this.context = context;
this._openMigrationDetails = openMigrationDetails;
this.serviceContextChangedEvent = serviceContextChangedEvent;
this.statusBar = statusBar;
await this.initialize();
@@ -71,29 +69,28 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
return this;
}
public onDialogClosed = async (): Promise<void> =>
await this.updateServiceContext(this._serviceContextButton);
public async setMigrationFilter(filter: AdsMigrationStatus): Promise<void> {
if (this._statusDropdown.values && this._statusDropdown.values.length > 0) {
const statusFilter = (<azdata.CategoryValue[]>this._statusDropdown.values)
.find(value => value.name === filter.toString());
this._statusDropdown.value = statusFilter;
await this._statusDropdown.updateProperties({ 'value': statusFilter });
}
}
public async refresh(): Promise<void> {
if (this.isRefreshing) {
if (this.isRefreshing ||
this._refreshLoader === undefined) {
return;
}
this.isRefreshing = true;
this._refresh.enabled = false;
this._refreshLoader.loading = true;
await this.statusBar.clearError();
try {
this.isRefreshing = true;
this._refreshLoader.loading = true;
await this.statusBar.clearError();
await this._statusTable.updateProperty('data', []);
this._migrations = await getCurrentMigrations();
await this._populateMigrationTable();
@@ -105,26 +102,22 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
logError(TelemetryViews.MigrationsTab, 'refreshMigrations', e);
} finally {
this._refreshLoader.loading = false;
this._refresh.enabled = true;
this.isRefreshing = false;
}
}
protected async initialize(): Promise<void> {
this._registerCommands();
this._createStatusTable();
this.content = this.view.modelBuilder.flexContainer()
.withItems(
[
this._createToolbar(),
await this._createSearchAndSortContainer(),
this._createStatusTable()
this._statusTable,
],
{ CSSStyles: { 'width': '100%' } }
).withLayout({
width: '100%',
flexFlow: 'column',
}).withProps({ CSSStyles: { 'padding': '0px' } })
).withLayout({ width: '100%', flexFlow: 'column' })
.withProps({ CSSStyles: { 'padding': '0px' } })
.component();
}
@@ -144,20 +137,16 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
async (e) => await this.refresh()));
this._refreshLoader = this.view.modelBuilder.loadingComponent()
.withItem(this._refresh)
.withProps({
loading: false,
CSSStyles: {
'height': '8px',
'margin-top': '6px'
}
})
.component();
CSSStyles: { 'height': '8px', 'margin-top': '6px' }
}).component();
toolbar.addToolbarItems([
<azdata.ToolbarComponent>{ component: this.createNewMigrationButton(), toolbarSeparatorAfter: true },
<azdata.ToolbarComponent>{ component: this.createNewSupportRequestButton() },
<azdata.ToolbarComponent>{ component: this.createFeedbackButton(), toolbarSeparatorAfter: true },
<azdata.ToolbarComponent>{ component: this._refresh },
<azdata.ToolbarComponent>{ component: this._refreshLoader },
]);
@@ -178,16 +167,25 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
width: 230,
}).component();
const onDialogClosed = async (): Promise<void> =>
await this.updateServiceContext(this._serviceContextButton);
this.disposables.push(
this._serviceContextButton.onDidClick(
async () => {
const dialog = new SelectMigrationServiceDialog(onDialogClosed);
const dialog = new SelectMigrationServiceDialog(this.serviceContextChangedEvent);
await dialog.initialize();
}));
const connectionProfile = await azdata.connection.getCurrentConnection();
this.disposables.push(
this.serviceContextChangedEvent.event(
async (e) => {
if (e.connectionId === connectionProfile.connectionId) {
await this.updateServiceContext(this._serviceContextButton);
await this.refresh();
}
}
));
await this.updateServiceContext(this._serviceContextButton);
this._searchBox = this.view.modelBuilder.inputBox()
.withProps({
stopEnterPropagation: true,
@@ -212,7 +210,9 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
.withProps({
ariaLabel: loc.MIGRATION_STATUS_FILTER,
values: this._statusDropdownValues,
width: '150px'
width: '150px',
fireOnTextChange: true,
value: this._statusDropdownValues[0],
}).component();
this.disposables.push(
this._statusDropdown.onValueChanged(
@@ -311,173 +311,6 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
return container;
}
private _registerCommands(): void {
this.disposables.push(vscode.commands.registerCommand(
MenuCommands.Cutover,
async (migrationId: string) => {
try {
await this.statusBar.clearError();
const migration = this._migrations.find(
migration => migration.id === migrationId);
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.statusBar.showError(
loc.MIGRATION_CUTOVER_ERROR,
loc.MIGRATION_CUTOVER_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.Cutover, e);
}
}));
this.disposables.push(vscode.commands.registerCommand(
MenuCommands.ViewDatabase,
async (migrationId: string) => {
try {
await this.statusBar.clearError();
const migration = this._migrations.find(m => m.id === migrationId);
await this._openMigrationDetails(migration!);
} catch (e) {
await this.statusBar.showError(
loc.OPEN_MIGRATION_DETAILS_ERROR,
loc.OPEN_MIGRATION_DETAILS_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.ViewDatabase, e);
}
}));
this.disposables.push(vscode.commands.registerCommand(
MenuCommands.ViewTarget,
async (migrationId: string) => {
try {
const migration = this._migrations.find(migration => migration.id === migrationId);
const url = 'https://portal.azure.com/#resource/' + migration!.properties.scope;
await vscode.env.openExternal(vscode.Uri.parse(url));
} catch (e) {
await this.statusBar.showError(
loc.OPEN_MIGRATION_TARGET_ERROR,
loc.OPEN_MIGRATION_TARGET_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.ViewTarget, e);
}
}));
this.disposables.push(vscode.commands.registerCommand(
MenuCommands.ViewService,
async (migrationId: string) => {
try {
await this.statusBar.clearError();
const migration = this._migrations.find(migration => migration.id === migrationId);
const dialog = new SqlMigrationServiceDetailsDialog(
await MigrationLocalStorage.getMigrationServiceContext(),
migration!);
await dialog.initialize();
} catch (e) {
await this.statusBar.showError(
loc.OPEN_MIGRATION_SERVICE_ERROR,
loc.OPEN_MIGRATION_SERVICE_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.ViewService, e);
}
}));
this.disposables.push(vscode.commands.registerCommand(
MenuCommands.CopyMigration,
async (migrationId: string) => {
await this.statusBar.clearError();
const migration = this._migrations.find(migration => migration.id === migrationId);
const cutoverDialogModel = new MigrationCutoverDialogModel(
await MigrationLocalStorage.getMigrationServiceContext(),
migration!);
try {
await cutoverDialogModel.fetchStatus();
} catch (e) {
await this.statusBar.showError(
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.disposables.push(vscode.commands.registerCommand(
MenuCommands.CancelMigration,
async (migrationId: string) => {
try {
await this.statusBar.clearError();
const migration = this._migrations.find(migration => migration.id === migrationId);
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.statusBar.showError(
loc.MIGRATION_CANCELLATION_ERROR,
loc.MIGRATION_CANCELLATION_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.CancelMigration, e);
}
}));
this.disposables.push(vscode.commands.registerCommand(
MenuCommands.RetryMigration,
async (migrationId: string) => {
try {
await this.statusBar.clearError();
const migration = this._migrations.find(migration => migration.id === migrationId);
if (canRetryMigration(migration)) {
let retryMigrationDialog = new RetryMigrationDialog(
this.context,
await MigrationLocalStorage.getMigrationServiceContext(),
migration!,
async () => await this.onDialogClosed());
await retryMigrationDialog.openDialog();
}
else {
await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_RETRY);
}
} catch (e) {
await this.statusBar.showError(
loc.MIGRATION_RETRY_ERROR,
loc.MIGRATION_RETRY_ERROR,
e.message);
logError(TelemetryViews.MigrationsTab, MenuCommands.RetryMigration, e);
}
}));
}
private _sortMigrations(migrations: DatabaseMigration[], columnName: string, ascending: boolean): void {
const sortDir = ascending ? -1 : 1;
switch (columnName) {
@@ -575,6 +408,7 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
(<azdata.CategoryValue>this._columnSortDropdown.value).name,
this._columnSortCheckbox.checked === true);
const connectionProfile = await azdata.connection.getCurrentConnection();
const data: any[] = this._filteredMigrations.map((migration, index) => {
return [
<azdata.HyperlinkColumnCellValue>{
@@ -597,7 +431,11 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
getMigrationTime(migration.properties.endedOn), // finishTime
<azdata.ContextMenuColumnCellValue>{
title: '',
context: migration.id,
context: {
connectionId: connectionProfile.connectionId,
migrationId: migration.id,
migrationOperationId: migration.properties.migrationOperationId,
},
commands: this._getMenuCommands(migration), // context menu
},
];
@@ -632,7 +470,6 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
value: 'sourceDatabase',
width: 190,
type: azdata.ColumnType.hyperlink,
showText: true,
},
{
cssClass: rowCssStyles,
@@ -717,25 +554,26 @@ export class MigrationsListTab extends TabBase<MigrationsListTab> {
]
}).component();
this.disposables.push(this._statusTable.onCellAction!(async (rowState: azdata.ICellActionEventArgs) => {
const buttonState = <azdata.ICellActionEventArgs>rowState;
const migration = this._filteredMigrations[rowState.row];
switch (buttonState?.column) {
case 2:
const status = getMigrationStatus(migration);
const statusMessage = loc.DATABASE_MIGRATION_STATUS_LABEL(status);
const errors = this.getMigrationErrors(migration!);
this.disposables.push(
this._statusTable.onCellAction!(async (rowState: azdata.ICellActionEventArgs) => {
const buttonState = <azdata.ICellActionEventArgs>rowState;
const migration = this._filteredMigrations[rowState.row];
switch (buttonState?.column) {
case 2:
const status = getMigrationStatus(migration);
const statusMessage = loc.DATABASE_MIGRATION_STATUS_LABEL(status);
const errors = this.getMigrationErrors(migration!);
this.showDialogMessage(
loc.DATABASE_MIGRATION_STATUS_TITLE,
statusMessage,
errors);
break;
case 0:
await this._openMigrationDetails(migration);
break;
}
}));
this.showDialogMessage(
loc.DATABASE_MIGRATION_STATUS_TITLE,
statusMessage,
errors);
break;
case 0:
await this._openMigrationDetails(migration);
break;
}
}));
return this._statusTable;
}