Adding migration status and cutover to extension (#14482)

This commit is contained in:
Aasim Khan
2021-03-02 17:11:17 -08:00
committed by GitHub
parent 1e67388653
commit f2ae5419bb
33 changed files with 1452 additions and 236 deletions

View File

@@ -7,6 +7,7 @@ import * as azdata from 'azdata';
import { MigrationStateModel } from '../../models/stateMachine';
import { SqlDatabaseTree } from './sqlDatabasesTree';
import { SqlMigrationImpactedObjectInfo } from '../../../../mssql/src/mssql';
import { SKURecommendationPage } from '../../wizard/skuRecommendationPage';
export type Issues = {
description: string,
@@ -30,7 +31,7 @@ export class AssessmentResultsDialog {
private _tree: SqlDatabaseTree;
constructor(public ownerUri: string, public model: MigrationStateModel, public title: string) {
constructor(public ownerUri: string, public model: MigrationStateModel, public title: string, private skuRecommendationPage: SKURecommendationPage) {
this._model = model;
let assessmentData = this.parseData(this._model);
this._tree = new SqlDatabaseTree(this._model, assessmentData);
@@ -126,6 +127,7 @@ export class AssessmentResultsDialog {
protected async execute() {
this.model._migrationDbs = this._tree.selectedDbs();
this.skuRecommendationPage.refreshDatabaseCount(this._model._migrationDbs.length);
this._isOpen = false;
}

View File

@@ -7,7 +7,7 @@ import * as azdata from 'azdata';
import * as vscode from 'vscode';
import { createMigrationController, getMigrationControllerRegions, getMigrationController, getResourceGroups, getMigrationControllerAuthKeys, getMigrationControllerMonitoringData } from '../../api/azure';
import { MigrationStateModel } from '../../models/stateMachine';
import * as constants from '../../models/strings';
import * as constants from '../../constants/strings';
import * as os from 'os';
import { azureResource } from 'azureResource';
import { IntergrationRuntimePage } from '../../wizard/integrationRuntimePage';
@@ -130,7 +130,6 @@ export class CreateMigrationControllerDialog {
this._dialogObject.okButton.enabled = false;
azdata.window.openDialog(this._dialogObject);
this._dialogObject.cancelButton.onClick((e) => {
this.migrationStateModel._migrationController = undefined!;
});
this._dialogObject.okButton.onClick((e) => {
this.irPage.populateMigrationController();

View File

@@ -0,0 +1,440 @@
/*---------------------------------------------------------------------------------------------
* 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 { IconPathHelper } from '../../constants/iconPathHelper';
import { MigrationContext } from '../../models/migrationLocalStorage';
import { MigrationCutoverDialogModel } from './migrationCutoverDialogModel';
import * as loc from '../../constants/strings';
export class MigrationCutoverDialog {
private _dialogObject!: azdata.window.Dialog;
private _view!: azdata.ModelView;
private _model: MigrationCutoverDialogModel;
private _databaseTitleName!: azdata.TextComponent;
private _databaseCutoverButton!: azdata.ButtonComponent;
private _refresh!: azdata.ButtonComponent;
private _serverName!: azdata.TextComponent;
private _serverVersion!: azdata.TextComponent;
private _targetServer!: azdata.TextComponent;
private _targetVersion!: azdata.TextComponent;
private _migrationStatus!: azdata.TextComponent;
private _fullBackupFile!: azdata.TextComponent;
private _lastAppliedLSN!: azdata.TextComponent;
private _lastAppliedBackupFile!: azdata.TextComponent;
private _lastAppliedBackupTakenOn!: azdata.TextComponent;
private _fileCount!: azdata.TextComponent;
private fileTable!: azdata.TableComponent;
private _startCutover!: boolean;
constructor(migration: MigrationContext) {
this._model = new MigrationCutoverDialogModel(migration);
this._dialogObject = azdata.window.createModelViewDialog(loc.MIGRATION_CUTOVER, 'MigrationCutoverDialog', 1000);
}
async initialize(): Promise<void> {
let tab = azdata.window.createTab('');
tab.registerContent(async (view: azdata.ModelView) => {
this._view = view;
const sourceDetails = this.createInfoField(loc.SOURCE_VERSION, '');
const sourceVersion = this.createInfoField(loc.SOURCE_VERSION, '');
this._serverName = sourceDetails.text;
this._serverVersion = sourceVersion.text;
const flexServer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column'
}).component();
flexServer.addItem(sourceDetails.flexContainer, {
CSSStyles: {
'width': '150px'
}
});
flexServer.addItem(sourceVersion.flexContainer, {
CSSStyles: {
'width': '150px'
}
});
const targetServer = this.createInfoField(loc.TARGET_SERVER, '');
const targetVersion = this.createInfoField(loc.TARGET_VERSION, '');
this._targetServer = targetServer.text;
this._targetVersion = targetVersion.text;
const flexTarget = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column'
}).component();
flexTarget.addItem(targetServer.flexContainer, {
CSSStyles: {
'width': '230px'
}
});
flexTarget.addItem(targetVersion.flexContainer, {
CSSStyles: {
'width': '230px'
}
});
const migrationStatus = this.createInfoField(loc.MIGRATION_STATUS, '');
const fullBackupFileOn = this.createInfoField(loc.FULL_BACKUP_FILES, '');
this._migrationStatus = migrationStatus.text;
this._fullBackupFile = fullBackupFileOn.text;
const flexStatus = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column'
}).component();
flexStatus.addItem(migrationStatus.flexContainer, {
CSSStyles: {
'width': '180px'
}
});
flexStatus.addItem(fullBackupFileOn.flexContainer, {
CSSStyles: {
'width': '180px'
}
});
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': '230px'
}
});
flexFile.addItem(lastAppliedBackup.flexContainer, {
CSSStyles: {
'width': '230px'
}
});
flexFile.addItem(lastAppliedBackupOn.flexContainer, {
CSSStyles: {
'width': '230px'
}
});
const flexInfo = view.modelBuilder.flexContainer().withProps({
CSSStyles: {
'width': '700px'
}
}).component();
flexInfo.addItem(flexServer, {
flex: '0',
CSSStyles: {
'flex': '0',
'width': '150px'
}
});
flexInfo.addItem(flexTarget, {
flex: '0',
CSSStyles: {
'flex': '0',
'width': '230px'
}
});
flexInfo.addItem(flexStatus, {
flex: '0',
CSSStyles: {
'flex': '0',
'width': '180px'
}
});
flexInfo.addItem(flexFile, {
flex: '0',
CSSStyles: {
'flex': '0',
'width': '200px'
}
});
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: 150,
type: azdata.ColumnType.text
},
{
value: loc.TYPE,
width: 100,
type: azdata.ColumnType.text
},
{
value: loc.STATUS,
width: 100,
type: azdata.ColumnType.text
},
{
value: loc.BACKUP_START_TIME,
width: 150,
type: azdata.ColumnType.text
}, {
value: loc.FIRST_LSN,
width: 150,
type: azdata.ColumnType.text
}, {
value: loc.LAST_LSN,
width: 150,
type: azdata.ColumnType.text
}
],
data: [],
width: '800px',
height: '600px',
}).component();
const formBuilder = view.modelBuilder.formContainer().withFormItems(
[
{
component: await this.migrationContainerHeader()
},
{
component: flexInfo
},
{
component: this._fileCount
},
{
component: this.fileTable
}
],
{
horizontal: false
}
);
const form = formBuilder.withLayout({ width: '100%' }).component();
return view.initializeModel(form);
});
this._dialogObject.content = [tab];
azdata.window.openDialog(this._dialogObject);
this.refreshStatus();
}
private migrationContainerHeader(): azdata.FlexContainer {
const header = this._view.modelBuilder.flexContainer().withLayout({
}).component();
this._databaseTitleName = this._view.modelBuilder.text().withProps({
CSSStyles: {
'font-size': 'large',
'width': '400px'
},
value: this._model._migration.migrationContext.name
}).component();
header.addItem(this._databaseTitleName, {
flex: '0',
CSSStyles: {
'width': '500px'
}
});
this._databaseCutoverButton = this._view.modelBuilder.button().withProps({
iconPath: IconPathHelper.cutover,
iconHeight: '14px',
iconWidth: '12px',
label: 'Start Cutover',
height: '55px',
width: '100px',
enabled: false
}).component();
this._databaseCutoverButton.onDidClick(async (e) => {
if (this._startCutover) {
await this._model.startCutover();
this.refreshStatus();
} else {
this._dialogObject.message = {
text: loc.CANNOT_START_CUTOVER_ERROR,
level: azdata.window.MessageLevel.Error
};
}
});
header.addItem(this._databaseCutoverButton, {
flex: '0',
CSSStyles: {
'width': '100px'
}
});
this._refresh = this._view.modelBuilder.button().withProps({
iconPath: IconPathHelper.refresh,
iconHeight: '16px',
iconWidth: '16px',
label: 'Refresh',
height: '55px',
width: '100px'
}).component();
this._refresh.onDidClick((e) => {
this.refreshStatus();
});
header.addItem(this._refresh, {
flex: '0',
CSSStyles: {
'width': '100px'
}
});
return header;
}
private async refreshStatus(): Promise<void> {
try {
await this._model.fetchStatus();
const sqlServerInfo = await azdata.connection.getServerInfo(this._model._migration.sourceConnectionProfile.connectionId);
const sqlServerName = this._model._migration.sourceConnectionProfile.serverName;
const sqlServerVersion = sqlServerInfo.serverVersion;
const sqlServerEdition = sqlServerInfo.serverEdition;
const targetServerName = this._model._migration.targetManagedInstance.name;
let targetServerVersion;
if (this._model.migrationStatus.id.includes('managedInstances')) {
targetServerVersion = loc.AZURE_SQL_DATABASE_MANAGED_INSTANCE;
} else {
targetServerVersion = loc.AZURE_SQL_DATABASE_VIRTUAL_MACHINE;
}
const migrationStatusTextValue = this._model.migrationStatus.properties.migrationStatus;
let fullBackupFileName: string;
let lastAppliedSSN: string;
let lastAppliedBackupFileTakenOn: string;
const tableData: ActiveBackupFileSchema[] = [];
this._model.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.forEach((activeBackupSet) => {
tableData.push(
{
fileName: activeBackupSet.listOfBackupFiles[0].fileName,
type: activeBackupSet.backupType,
status: activeBackupSet.listOfBackupFiles[0].status,
backupStartTime: activeBackupSet.backupStartDate,
firstLSN: activeBackupSet.firstLSN,
lastLSN: activeBackupSet.lastLSN
}
);
if (activeBackupSet.listOfBackupFiles[0].fileName.substr(activeBackupSet.listOfBackupFiles[0].fileName.lastIndexOf('.') + 1) === 'bak') {
fullBackupFileName = activeBackupSet.listOfBackupFiles[0].fileName;
}
if (activeBackupSet.listOfBackupFiles[0].fileName === this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename) {
lastAppliedSSN = activeBackupSet.lastLSN;
lastAppliedBackupFileTakenOn = activeBackupSet.backupFinishDate;
}
});
this._serverName.value = sqlServerName;
this._serverVersion.value = `${sqlServerVersion}
${sqlServerEdition}`;
this._targetServer.value = targetServerName;
this._targetVersion.value = targetServerVersion;
this._migrationStatus.value = migrationStatusTextValue;
this._fullBackupFile.value = fullBackupFileName!;
this._lastAppliedLSN.value = lastAppliedSSN!;
this._lastAppliedBackupFile.value = this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename;
this._lastAppliedBackupTakenOn.value = new Date(lastAppliedBackupFileTakenOn!).toLocaleString();
this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length);
this.fileTable.data = tableData.map((row) => {
return [
row.fileName,
row.type,
row.status,
new Date(row.backupStartTime).toLocaleString(),
row.firstLSN,
row.lastLSN
];
});
if (this._model.migrationStatus.properties.migrationStatusDetails?.isFullBackupRestored) {
this._startCutover = true;
}
if (migrationStatusTextValue === 'InProgress') {
this._databaseCutoverButton.enabled = true;
} else {
this._databaseCutoverButton.enabled = false;
}
} catch (e) {
console.log(e);
}
}
private createInfoField(label: string, value: string): {
flexContainer: azdata.FlexContainer,
text: azdata.TextComponent
} {
const flexContainer = this._view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column'
}).component();
const labelComponent = this._view.modelBuilder.text().withProps({
value: label,
CSSStyles: {
'font-weight': 'bold',
'margin-bottom': '0'
}
}).component();
flexContainer.addItem(labelComponent);
const textComponent = this._view.modelBuilder.text().withProps({
value: value,
CSSStyles: {
'margin-top': '5px',
'margin-bottom': '0'
}
}).component();
flexContainer.addItem(textComponent);
return {
flexContainer: flexContainer,
text: textComponent
};
}
}
interface ActiveBackupFileSchema {
fileName: string,
type: string,
status: string,
backupStartTime: string,
firstLSN: string,
lastLSN: string
}

View File

@@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getMigrationStatus, DatabaseMigration, startMigrationCutover } from '../../api/azure';
import { MigrationContext } from '../../models/migrationLocalStorage';
export class MigrationCutoverDialogModel {
public migrationStatus!: DatabaseMigration;
constructor(public _migration: MigrationContext) {
}
public async fetchStatus(): Promise<void> {
this.migrationStatus = (await getMigrationStatus(
this._migration.azureAccount,
this._migration.subscription,
this._migration.migrationContext
));
}
public async startCutover(): Promise<DatabaseMigration | undefined> {
try {
if (this.migrationStatus) {
return await startMigrationCutover(
this._migration.azureAccount,
this._migration.subscription,
this.migrationStatus
);
}
} catch (error) {
console.log(error);
}
return undefined!;
}
}

View File

@@ -0,0 +1,248 @@
/*---------------------------------------------------------------------------------------------
* 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 } from '../../models/migrationLocalStorage';
import { MigrationCutoverDialog } from '../migrationCutover/migrationCutoverDialog';
import { MigrationCategory, MigrationStatusDialogModel } from './migrationStatusDialogModel';
import * as loc from '../../constants/strings';
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;
constructor(migrations: MigrationContext[], private _filter: MigrationCategory) {
this._model = new MigrationStatusDialogModel(migrations);
this._dialogObject = azdata.window.createModelViewDialog(loc.MIGRATION_STATUS, 'MigrationControllerDialog', 'wide');
}
initialize() {
let tab = azdata.window.createTab('');
tab.registerContent((view: azdata.ModelView) => {
this._view = view;
this._statusDropdown = this._view.modelBuilder.dropDown().withProps({
values: this._model.statusDropdownValues,
width: '220px'
}).component();
this._statusDropdown.onValueChanged((value) => {
this.populateMigrationTable();
});
this._statusDropdown.value = this._statusDropdown.values![this._filter];
const formBuilder = view.modelBuilder.formContainer().withFormItems(
[
{
component: this.createSearchAndRefreshContainer()
},
{
component: this._statusDropdown
},
{
component: this.createStatusTable()
}
],
{
horizontal: false
}
);
const form = formBuilder.withLayout({ width: '100%' }).component();
return view.initializeModel(form);
});
this._dialogObject.content = [tab];
azdata.window.openDialog(this._dialogObject);
}
private createSearchAndRefreshContainer(): azdata.FlexContainer {
this._searchBox = this._view.modelBuilder.inputBox().withProps({
placeHolder: loc.SEARCH_FOR_MIGRATIONS,
width: '360px'
}).component();
this._searchBox.onTextChanged((value) => {
this.populateMigrationTable();
});
this._refresh = this._view.modelBuilder.button().withProps({
iconPath: {
light: IconPathHelper.refresh.light,
dark: IconPathHelper.refresh.dark
},
iconHeight: '16px',
iconWidth: '16px',
height: '30px',
label: 'Refresh',
}).component();
const flexContainer = this._view.modelBuilder.flexContainer().component();
flexContainer.addItem(this._searchBox, {
flex: '0'
});
flexContainer.addItem(this._refresh, {
flex: '0',
CSSStyles: {
'margin-left': '20px'
}
});
return flexContainer;
}
private populateMigrationTable(): void {
try {
const migrations = this._model.filterMigration(
this._searchBox.value!,
(<azdata.CategoryValue>this._statusDropdown.value).name
);
const data: azdata.DeclarativeTableCellValue[][] = [];
migrations.forEach((migration) => {
const migrationRow: azdata.DeclarativeTableCellValue[] = [];
const databaseHyperLink = this._view.modelBuilder.hyperlink().withProps({
label: migration.migrationContext.name,
url: ''
}).component();
databaseHyperLink.onDidClick(async (e) => {
await (new MigrationCutoverDialog(migration)).initialize();
});
migrationRow.push({
value: databaseHyperLink,
});
migrationRow.push({
value: migration.migrationContext.properties.migrationStatus
});
const sqlMigrationIcon = this._view.modelBuilder.image().withProps({
iconPath: IconPathHelper.sqlMigrationLogo,
iconWidth: '16px',
iconHeight: '16px',
width: '32px',
height: '20px'
}).component();
const sqlMigrationName = this._view.modelBuilder.hyperlink().withProps({
label: migration.migrationContext.name,
url: ''
}).component();
sqlMigrationName.onDidClick((e) => {
vscode.window.showInformationMessage('Feature coming soon');
});
const sqlMigrationContainer = this._view.modelBuilder.flexContainer().withProps({
CSSStyles: {
'justify-content': 'center'
}
}).component();
sqlMigrationContainer.addItem(sqlMigrationIcon, {
flex: '0',
CSSStyles: {
'width': '32px'
}
});
sqlMigrationContainer.addItem(sqlMigrationName,
{
CSSStyles: {
'width': 'auto'
}
});
migrationRow.push({
value: sqlMigrationContainer
});
migrationRow.push({
value: loc.ONLINE
});
migrationRow.push({
value: '---'
});
migrationRow.push({
value: '---'
});
data.push(migrationRow);
});
this._statusTable.dataValues = data;
} catch (e) {
console.log(e);
}
}
private createStatusTable(): azdata.DeclarativeTableComponent {
this._statusTable = this._view.modelBuilder.declarativeTable().withProps({
columns: [
{
displayName: loc.DATABASE,
valueType: azdata.DeclarativeDataType.component,
width: '100px',
isReadOnly: true,
rowCssStyles: {
'text-align': 'center'
}
},
{
displayName: loc.MIGRATION_STATUS,
valueType: azdata.DeclarativeDataType.string,
width: '150px',
isReadOnly: true,
rowCssStyles: {
'text-align': 'center'
}
},
{
displayName: loc.TARGET_AZURE_SQL_INSTANCE_NAME,
valueType: azdata.DeclarativeDataType.component,
width: '300px',
isReadOnly: true,
rowCssStyles: {
'text-align': 'center'
}
},
{
displayName: loc.CUTOVER_TYPE,
valueType: azdata.DeclarativeDataType.string,
width: '100px',
isReadOnly: true,
rowCssStyles: {
'text-align': 'center'
}
},
{
displayName: loc.START_TIME,
valueType: azdata.DeclarativeDataType.string,
width: '150px',
isReadOnly: true,
rowCssStyles: {
'text-align': 'center'
}
},
{
displayName: loc.FINISH_TIME,
valueType: azdata.DeclarativeDataType.string,
width: '150px',
isReadOnly: true,
rowCssStyles: {
'text-align': 'center'
}
}
]
}).component();
return this._statusTable;
}
}

View File

@@ -0,0 +1,56 @@
/*---------------------------------------------------------------------------------------------
* 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 { MigrationContext } from '../../models/migrationLocalStorage';
export class MigrationStatusDialogModel {
public statusDropdownValues: azdata.CategoryValue[] = [
{
displayName: 'Status: All',
name: 'All',
}, {
displayName: 'Status: Ongoing',
name: 'Ongoing',
}, {
displayName: 'Status: Succeeded',
name: 'Succeeded',
}
];
constructor(public _migrations: MigrationContext[]) {
}
public filterMigration(databaseName: string, category: string): MigrationContext[] {
let filteredMigration: MigrationContext[] = [];
if (category === 'All') {
filteredMigration = this._migrations;
} else if (category === 'Ongoing') {
filteredMigration = this._migrations.filter((value) => {
const status = value.migrationContext.properties.migrationStatus;
return status === 'InProgress' || status === 'Creating' || status === 'Completing';
});
} else if (category === 'Succeeded') {
filteredMigration = this._migrations.filter((value) => {
const status = value.migrationContext.properties.migrationStatus;
return status === 'Succeeded';
});
}
if (databaseName) {
filteredMigration = filteredMigration.filter((value) => {
return value.migrationContext.name.toLowerCase().includes(databaseName.toLowerCase());
});
}
return filteredMigration;
}
}
export enum MigrationCategory {
ALL,
ONGOING,
SUCCEEDED
}