Files
azuredatastudio/extensions/sql-migration/src/dashboard/migrationDetailsTabBase.ts
brian-harris ef02e2bfce SQL-Migration: add new migration monitoring data to migration details (#22460)
* add new migration details

* move migraiton target type enum to utils

* address review feedback, refectore, text update

* fix variable name

* limit and filter migrations list to mi/vm/db
2023-03-29 07:48:30 -07:00

533 lines
16 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* 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 { MigrationServiceContext } from '../models/migrationLocalStorage';
import * as loc from '../constants/strings';
import { DatabaseMigration, deleteMigration } from '../api/azure';
import { TabBase } from './tabBase';
import { MigrationCutoverDialogModel } from '../dialog/migrationCutover/migrationCutoverDialogModel';
import { ConfirmCutoverDialog } from '../dialog/migrationCutover/confirmCutoverDialog';
import { RetryMigrationDialog } from '../dialog/retryMigration/retryMigrationDialog';
import { DashboardStatusBar } from './DashboardStatusBar';
import { canDeleteMigration } from '../constants/helper';
import { logError, TelemetryViews } from '../telemetry';
import { MenuCommands, MigrationTargetType } from '../api/utils';
export const infoFieldLgWidth: string = '330px';
export const infoFieldWidth: string = '250px';
const statusImageSize: number = 14;
export const MigrationTargetTypeName: loc.LookupTable<string> = {
[MigrationTargetType.SQLMI]: loc.SQL_MANAGED_INSTANCE,
[MigrationTargetType.SQLVM]: loc.SQL_VIRTUAL_MACHINE,
[MigrationTargetType.SQLDB]: loc.SQL_DATABASE,
};
export interface InfoFieldSchema {
flexContainer: azdata.FlexContainer,
text: azdata.TextComponent,
icon?: azdata.ImageComponent,
}
export abstract class MigrationDetailsTabBase<T> extends TabBase<T> {
protected model!: MigrationCutoverDialogModel;
protected databaseLabel!: azdata.TextComponent;
protected serviceContext!: MigrationServiceContext;
protected openMigrationsListFcn!: (refresh?: boolean) => Promise<void>;
protected cutoverButton!: azdata.ButtonComponent;
protected refreshButton!: azdata.ButtonComponent;
protected cancelButton!: azdata.ButtonComponent;
protected deleteButton!: azdata.ButtonComponent;
protected refreshLoader!: azdata.LoadingComponent;
protected copyDatabaseMigrationDetails!: azdata.ButtonComponent;
protected newSupportRequest!: azdata.ButtonComponent;
protected retryButton!: azdata.ButtonComponent;
protected summaryTextComponent: azdata.TextComponent[] = [];
public abstract create(
context: vscode.ExtensionContext,
view: azdata.ModelView,
openMigrationsListFcn: (refresh?: boolean) => Promise<void>,
statusBar: DashboardStatusBar): Promise<T>;
protected abstract migrationInfoGrid(): Promise<azdata.FlexContainer>;
constructor() {
super();
this.title = '';
}
public async setMigrationContext(
serviceContext: MigrationServiceContext,
migration: DatabaseMigration): Promise<void> {
this.serviceContext = serviceContext;
this.model = new MigrationCutoverDialogModel(serviceContext, migration);
await this.refresh(true);
}
protected createBreadcrumbContainer(): azdata.FlexContainer {
const migrationsTabLink = this.view.modelBuilder.hyperlink()
.withProps({
label: loc.BREADCRUMB_MIGRATIONS,
url: '',
title: loc.BREADCRUMB_MIGRATIONS,
CSSStyles: {
'padding': '5px 5px 5px 0',
'font-size': '13px'
}
})
.component();
this.disposables.push(
migrationsTabLink.onDidClick(
async (e) => await this.openMigrationsListFcn()));
const breadCrumbImage = this.view.modelBuilder.image()
.withProps({
iconPath: IconPathHelper.breadCrumb,
iconHeight: 8,
iconWidth: 8,
width: 8,
height: 8,
CSSStyles: { 'padding': '4px' }
}).component();
this.databaseLabel = this.view.modelBuilder.text()
.withProps({
textType: azdata.TextType.Normal,
value: '...',
CSSStyles: {
'font-size': '16px',
'font-weight': '600',
'margin-block-start': '0',
'margin-block-end': '0',
}
}).component();
return this.view.modelBuilder.flexContainer()
.withItems(
[migrationsTabLink, breadCrumbImage, this.databaseLabel],
{ flex: '0 0 auto' })
.withLayout({
flexFlow: 'row',
alignItems: 'center',
alignContent: 'center',
})
.withProps({
height: 20,
CSSStyles: { 'padding': '0', 'margin-bottom': '5px' }
})
.component();
}
protected createMigrationToolbarContainer(): azdata.FlexContainer {
const toolbarContainer = this.view.modelBuilder.toolbarContainer();
const buttonHeight = 20;
this.cutoverButton = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.cutover,
iconHeight: '16px',
iconWidth: '16px',
label: loc.COMPLETE_CUTOVER,
height: buttonHeight,
enabled: false,
CSSStyles: { 'display': 'none' }
}).component();
this.disposables.push(
this.cutoverButton.onDidClick(async (e) => {
await this.statusBar.clearError();
await this.refresh();
const dialog = new ConfirmCutoverDialog(this.model);
await dialog.initialize();
if (this.model.CutoverError) {
await this.statusBar.showError(
loc.MIGRATION_CUTOVER_ERROR,
loc.MIGRATION_CUTOVER_ERROR,
this.model.CutoverError.message);
}
}));
this.cancelButton = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.cancel,
iconHeight: '16px',
iconWidth: '16px',
label: loc.CANCEL_MIGRATION,
height: buttonHeight,
enabled: false,
}).component();
this.disposables.push(
this.cancelButton.onDidClick((e) => {
void vscode.window.showInformationMessage(
loc.CANCEL_MIGRATION_CONFIRMATION,
{ modal: true },
loc.YES,
loc.NO
).then(async (v) => {
if (v === loc.YES) {
await this.statusBar.clearError();
await this.model.cancelMigration();
await this.refresh();
if (this.model.CancelMigrationError) {
{
await this.statusBar.showError(
loc.MIGRATION_CANCELLATION_ERROR,
loc.MIGRATION_CANCELLATION_ERROR,
this.model.CancelMigrationError.message);
}
}
}
});
}));
this.deleteButton = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.discard,
iconHeight: '16px',
iconWidth: '16px',
label: loc.DELETE_MIGRATION,
height: buttonHeight,
enabled: false,
}).component();
this.disposables.push(
this.deleteButton.onDidClick(
async (e) => {
await this.statusBar.clearError();
try {
if (canDeleteMigration(this.model.migration)) {
const response = await vscode.window.showInformationMessage(
loc.DELETE_MIGRATION_CONFIRMATION,
{ modal: true },
loc.YES,
loc.NO);
if (response === loc.YES) {
await deleteMigration(
this.serviceContext.azureAccount!,
this.serviceContext.subscription!,
this.model.migration.id);
await this.openMigrationsListFcn(true);
}
} else {
await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_DELETE);
logError(TelemetryViews.MigrationDetailsTab, MenuCommands.DeleteMigration, "cannot delete migration");
}
} catch (e) {
await this.statusBar.showError(
loc.MIGRATION_DELETE_ERROR,
loc.MIGRATION_DELETE_ERROR,
e.message);
logError(TelemetryViews.MigrationDetailsTab, MenuCommands.DeleteMigration, e);
}
}));
this.retryButton = this.view.modelBuilder.button()
.withProps({
label: loc.RETRY_MIGRATION,
iconPath: IconPathHelper.retry,
enabled: false,
iconHeight: '16px',
iconWidth: '16px',
height: buttonHeight,
}).component();
this.disposables.push(
this.retryButton.onDidClick(
async (e) => {
await this.refresh();
const retryMigrationDialog = new RetryMigrationDialog(
this.context,
this.serviceContext,
this.model.migration,
this.serviceContextChangedEvent);
await retryMigrationDialog.openDialog();
}
));
this.copyDatabaseMigrationDetails = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.copy,
iconHeight: '16px',
iconWidth: '16px',
label: loc.COPY_MIGRATION_DETAILS,
height: buttonHeight,
}).component();
this.disposables.push(
this.copyDatabaseMigrationDetails.onDidClick(async (e) => {
await this.refresh();
await vscode.env.clipboard.writeText(this._getMigrationDetails());
void vscode.window.showInformationMessage(loc.DETAILS_COPIED);
}));
this.newSupportRequest = this.view.modelBuilder.button()
.withProps({
label: loc.NEW_SUPPORT_REQUEST,
iconPath: IconPathHelper.newSupportRequest,
iconHeight: '16px',
iconWidth: '16px',
height: buttonHeight,
}).component();
this.disposables.push(
this.newSupportRequest.onDidClick(async (e) => {
const serviceId = this.model.migration.properties.migrationService;
const supportUrl = `https://portal.azure.com/#resource${serviceId}/supportrequest`;
await vscode.env.openExternal(vscode.Uri.parse(supportUrl));
}));
this.refreshButton = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.refresh,
iconHeight: '16px',
iconWidth: '16px',
label: loc.REFRESH_BUTTON_TEXT,
height: buttonHeight,
}).component();
this.disposables.push(
this.refreshButton.onDidClick(
async (e) => await this.refresh()));
this.refreshLoader = this.view.modelBuilder.loadingComponent()
.withItem(this.refreshButton)
.withProps({
loading: false,
CSSStyles: { 'height': '8px', 'margin-top': '4px' }
}).component();
toolbarContainer.addToolbarItems([
<azdata.ToolbarComponent>{ component: this.cutoverButton },
<azdata.ToolbarComponent>{ component: this.cancelButton },
<azdata.ToolbarComponent>{ component: this.deleteButton },
<azdata.ToolbarComponent>{ component: this.retryButton },
<azdata.ToolbarComponent>{ component: this.copyDatabaseMigrationDetails, toolbarSeparatorAfter: true },
<azdata.ToolbarComponent>{ component: this.newSupportRequest, toolbarSeparatorAfter: true },
<azdata.ToolbarComponent>{ component: this.refreshLoader },
]);
return this.view.modelBuilder.flexContainer()
.withItems([
this.createBreadcrumbContainer(),
toolbarContainer.component(),
])
.withLayout({ flexFlow: 'column', width: '100%' })
.component();
}
protected async createInfoCard(
label: string,
iconPath: azdata.IconPath
): Promise<azdata.FlexContainer> {
const defaultValue = (0).toLocaleString();
const flexContainer = this.view.modelBuilder.flexContainer()
.withProps({
width: 168,
CSSStyles: {
'flex-direction': 'column',
'margin': '0 12px 0 0',
'box-sizing': 'border-box',
'border': '1px solid rgba(204, 204, 204, 0.5)',
'box-shadow': '0px 2px 4px rgba(0, 0, 0, 0.1)',
'border-radius': '2px',
}
}).component();
const labelComponent = this.view.modelBuilder.text()
.withProps({
value: label,
CSSStyles: {
'font-size': '13px',
'font-weight': '600',
'margin': '5px',
}
}).component();
flexContainer.addItem(labelComponent);
const iconComponent = this.view.modelBuilder.image()
.withProps({
iconPath: iconPath,
iconHeight: 16,
iconWidth: 16,
height: 16,
width: 16,
CSSStyles: {
'margin': '5px 5px 5px 5px',
'padding': '0'
}
}).component();
const textComponent = this.view.modelBuilder.text()
.withProps({
value: defaultValue,
title: defaultValue,
CSSStyles: {
'font-size': '20px',
'font-weight': '600',
'margin': '0 5px 0 5px'
}
}).component();
this.summaryTextComponent.push(textComponent);
const iconTextComponent = this.view.modelBuilder.flexContainer()
.withItems([iconComponent, textComponent])
.withLayout({ alignItems: 'center' })
.withProps({
CSSStyles: {
'flex-direction': 'row',
'margin': '0 0 0 5px',
'padding': '0',
},
display: 'inline-flex'
}).component();
flexContainer.addItem(iconTextComponent);
return flexContainer;
}
protected async createInfoField(label: string, value: string, defaultHidden: boolean = false, iconPath?: azdata.IconPath): Promise<{
flexContainer: azdata.FlexContainer,
text: azdata.TextComponent,
icon?: azdata.ImageComponent
}> {
const flexContainer = this.view.modelBuilder.flexContainer()
.withProps({ CSSStyles: { 'flex-direction': 'row', } })
.component();
const labelComponent = this.view.modelBuilder.text()
.withProps({
value: label,
title: label,
width: '170px',
CSSStyles: {
'font-size': '13px',
'line-height': '18px',
'margin': '2px 0 2px 0',
'float': 'left',
'overflow': 'hidden',
'text-overflow': 'ellipsis',
'display': 'inline-block',
'white-space': 'nowrap',
}
}).component();
flexContainer.addItem(labelComponent, { flex: '0 0 auto' });
const separatorComponent = this.view.modelBuilder.text()
.withProps({
value: ':',
title: ':',
width: '15px',
CSSStyles: {
'font-size': '13px',
'line-height': '18px',
'margin': '2px 0 2px 0',
'float': 'left',
}
}).component();
flexContainer.addItem(separatorComponent, { flex: '0 0 auto' });
const textComponent = this.view.modelBuilder.text()
.withProps({
value: value,
title: value,
width: '300px',
CSSStyles: {
'font-size': '13px',
'line-height': '18px',
'margin': '2px 15px 2px 0',
'overflow': 'hidden',
'text-overflow': 'ellipsis',
'display': 'inline-block',
'float': 'left',
'white-space': 'nowrap',
}
}).component();
let iconComponent;
if (iconPath) {
iconComponent = this.view.modelBuilder.image()
.withProps({
iconPath: (iconPath === ' ') ? undefined : iconPath,
iconHeight: statusImageSize,
iconWidth: statusImageSize,
height: statusImageSize,
width: statusImageSize,
title: value,
CSSStyles: {
'margin': '4px 4px 0 0',
'padding': '0'
}
}).component();
const iconTextComponent = this.view.modelBuilder.flexContainer()
.withItems([iconComponent, textComponent])
.withProps({
CSSStyles: {
'margin': '0',
'padding': '0',
'height': '18px',
},
display: 'inline-flex'
}).component();
flexContainer.addItem(iconTextComponent, { flex: '0 0 auto' });
} else {
flexContainer.addItem(textComponent, { flex: '0 0 auto' });
}
return {
flexContainer: flexContainer,
text: textComponent,
icon: iconComponent
};
}
protected async showMigrationErrors(migration: DatabaseMigration): Promise<void> {
const errorMessage = this.getMigrationErrors(migration);
if (errorMessage?.length > 0) {
await this.statusBar.showError(
loc.MIGRATION_ERROR_DETAILS_TITLE,
loc.MIGRATION_ERROR_DETAILS_LABEL,
errorMessage);
}
}
protected getMigrationCurrentlyRestoringFile(migration: DatabaseMigration): string | undefined {
const lastAppliedBackupFile = this.getMigrationLastAppliedBackupFile(migration);
const currentRestoringFile = migration?.properties?.migrationStatusDetails?.currentRestoringFilename;
return currentRestoringFile === lastAppliedBackupFile
&& currentRestoringFile && currentRestoringFile.length > 0
? loc.ALL_BACKUPS_RESTORED
: currentRestoringFile;
}
protected getMigrationLastAppliedBackupFile(migration: DatabaseMigration): string | undefined {
return migration?.properties?.migrationStatusDetails?.lastRestoredFilename
|| migration?.properties?.offlineConfiguration?.lastBackupName;
}
private _getMigrationDetails(): string {
return JSON.stringify(this.model.migration, undefined, 2);
}
protected _updateInfoFieldValue(info: InfoFieldSchema, value: string) {
info.text.value = value;
info.text.title = value;
info.text.description = value;
if (info.icon) {
info.icon.title = value;
}
}
}