Add new tabbed dashboard, monitoring with breadcrumb navigation (#19995)

* SQL DB monitoring and  Dashboard refactor

* Merge remote-tracking branch 'origin/main' into dev/brih/feature/sql-migration-dashboard-tabs

* update filter text and optimize page load

* update migration column order, names and statusbox

* add column table sorting

* add new migration and pipeline status values, etc

* address review feedback
This commit is contained in:
brian-harris
2022-07-25 10:06:17 -07:00
committed by GitHub
parent db39571394
commit 78b7c3cfd4
27 changed files with 4192 additions and 2382 deletions

View File

@@ -0,0 +1,789 @@
/*---------------------------------------------------------------------------------------------
* 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 { IconPath, IconPathHelper } from '../constants/iconPathHelper';
import * as styles from '../constants/styles';
import * as loc from '../constants/strings';
import { filterMigrations } from '../api/utils';
import { DatabaseMigration } from '../api/azure';
import { getCurrentMigrations, getSelectedServiceStatus, isServiceContextValid, MigrationLocalStorage } from '../models/migrationLocalStorage';
import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog';
import { logError, TelemetryViews } from '../telemtery';
import { AdsMigrationStatus, MenuCommands, TabBase } from './tabBase';
import { DashboardStatusBar } from './sqlServerDashboard';
interface IActionMetadata {
title?: string,
description?: string,
link?: string,
iconPath?: azdata.ThemedIconPath,
command?: string;
}
interface StatusCard {
container: azdata.DivContainer;
count: azdata.TextComponent,
textContainer?: azdata.FlexContainer,
warningContainer?: azdata.FlexContainer,
warningText?: azdata.TextComponent,
}
export const DashboardTabId = 'DashboardTab';
const maxWidth = 800;
const BUTTON_CSS = {
'font-size': '13px',
'line-height': '18px',
'margin': '4px 0',
'text-align': 'left',
};
export class DashboardTab extends TabBase<DashboardTab> {
private _migrationStatusCardsContainer!: azdata.FlexContainer;
private _migrationStatusCardLoadingContainer!: azdata.LoadingComponent;
private _inProgressMigrationButton!: StatusCard;
private _inProgressWarningMigrationButton!: StatusCard;
private _allMigrationButton!: StatusCard;
private _successfulMigrationButton!: StatusCard;
private _failedMigrationButton!: StatusCard;
private _completingMigrationButton!: StatusCard;
private _selectServiceText!: azdata.TextComponent;
private _serviceContextButton!: azdata.ButtonComponent;
private _refreshButton!: azdata.ButtonComponent;
constructor() {
super();
this.title = loc.DESKTOP_DASHBOARD_TAB_TITLE;
this.id = DashboardTabId;
this.icon = IconPathHelper.sqlMigrationLogo;
}
public onDialogClosed = async (): Promise<void> =>
await this.updateServiceContext(this._serviceContextButton);
public async create(
view: azdata.ModelView,
openMigrationsFcn: (status: AdsMigrationStatus) => Promise<void>,
statusBar: DashboardStatusBar): Promise<DashboardTab> {
this.view = view;
this.openMigrationFcn = openMigrationsFcn;
this.statusBar = statusBar;
await this.initialize(this.view);
return this;
}
public async refresh(): Promise<void> {
if (this.isRefreshing) {
return;
}
this.isRefreshing = true;
this._migrationStatusCardLoadingContainer.loading = true;
let migrations: DatabaseMigration[] = [];
try {
await this.statusBar.clearError();
migrations = await getCurrentMigrations();
} catch (e) {
await this.statusBar.showError(
loc.DASHBOARD_REFRESH_MIGRATIONS_TITLE,
loc.DASHBOARD_REFRESH_MIGRATIONS_LABEL,
e.message);
logError(TelemetryViews.SqlServerDashboard, 'RefreshgMigrationFailed', e);
}
const inProgressMigrations = filterMigrations(migrations, AdsMigrationStatus.ONGOING);
let warningCount = 0;
for (let i = 0; i < inProgressMigrations.length; i++) {
if (inProgressMigrations[i].properties.migrationFailureError?.message ||
inProgressMigrations[i].properties.migrationStatusDetails?.fileUploadBlockingErrors ||
inProgressMigrations[i].properties.migrationStatusDetails?.restoreBlockingReason) {
warningCount += 1;
}
}
if (warningCount > 0) {
this._inProgressWarningMigrationButton.warningText!.value = loc.MIGRATION_INPROGRESS_WARNING(warningCount);
this._inProgressMigrationButton.container.display = 'none';
this._inProgressWarningMigrationButton.container.display = '';
} else {
this._inProgressMigrationButton.container.display = '';
this._inProgressWarningMigrationButton.container.display = 'none';
}
this._inProgressMigrationButton.count.value = inProgressMigrations.length.toString();
this._inProgressWarningMigrationButton.count.value = inProgressMigrations.length.toString();
this._updateStatusCard(migrations, this._successfulMigrationButton, AdsMigrationStatus.SUCCEEDED, true);
this._updateStatusCard(migrations, this._failedMigrationButton, AdsMigrationStatus.FAILED);
this._updateStatusCard(migrations, this._completingMigrationButton, AdsMigrationStatus.COMPLETING);
this._updateStatusCard(migrations, this._allMigrationButton, AdsMigrationStatus.ALL, true);
await this._updateSummaryStatus();
this.isRefreshing = false;
this._migrationStatusCardLoadingContainer.loading = false;
}
protected async initialize(view: azdata.ModelView): Promise<void> {
const container = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
width: '100%',
height: '100%'
}).component();
const toolbar = view.modelBuilder.toolbarContainer();
toolbar.addToolbarItems([
<azdata.ToolbarComponent>{ component: this.createNewMigrationButton() },
<azdata.ToolbarComponent>{ component: this.createNewSupportRequestButton() },
<azdata.ToolbarComponent>{ component: this.createFeedbackButton() },
]);
container.addItem(
toolbar.component(),
{ CSSStyles: { 'flex': '0 0 auto' } });
const header = this._createHeader(view);
// Files need to have the vscode-file scheme to be loaded by ADS
const watermarkUri = vscode.Uri
.file(<string>IconPathHelper.migrationDashboardHeaderBackground.light)
.with({ scheme: 'vscode-file' });
container.addItem(header, {
CSSStyles: {
'background-image': `
url(${watermarkUri}),
linear-gradient(0deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0) 100%)`,
'background-repeat': 'no-repeat',
'background-position': '91.06% 100%',
'margin-bottom': '20px'
}
});
const tasksContainer = await this._createTasks(view);
header.addItem(tasksContainer, {
CSSStyles: {
'width': `${maxWidth}px`,
'margin': '24px'
}
});
container.addItem(
await this._createFooter(view),
{ CSSStyles: { 'margin': '0 24px' } });
this.content = container;
}
private _createHeader(view: azdata.ModelView): azdata.FlexContainer {
const header = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
width: maxWidth,
}).component();
const titleComponent = view.modelBuilder.text()
.withProps({
value: loc.DASHBOARD_TITLE,
width: '750px',
CSSStyles: { ...styles.DASHBOARD_TITLE_CSS }
}).component();
const descriptionComponent = view.modelBuilder.text()
.withProps({
value: loc.DASHBOARD_DESCRIPTION,
CSSStyles: { ...styles.NOTE_CSS }
}).component();
header.addItems([titleComponent, descriptionComponent], {
CSSStyles: {
'width': `${maxWidth}px`,
'padding-left': '24px'
}
});
return header;
}
private async _createTasks(view: azdata.ModelView): Promise<azdata.Component> {
const tasksContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'row',
width: '100%',
}).component();
const migrateButtonMetadata: IActionMetadata = {
title: loc.DASHBOARD_MIGRATE_TASK_BUTTON_TITLE,
description: loc.DASHBOARD_MIGRATE_TASK_BUTTON_DESCRIPTION,
iconPath: IconPathHelper.sqlMigrationLogo,
command: MenuCommands.StartMigration
};
const preRequisiteListTitle = view.modelBuilder.text()
.withProps({
value: loc.PRE_REQ_TITLE,
CSSStyles: {
...styles.BODY_CSS,
'margin': '0px',
}
}).component();
const migrateButton = this._createTaskButton(view, migrateButtonMetadata);
const preRequisiteListElement = view.modelBuilder.text()
.withProps({
value: [
loc.PRE_REQ_1,
loc.PRE_REQ_2,
loc.PRE_REQ_3
],
CSSStyles: {
...styles.SMALL_NOTE_CSS,
'padding-left': '12px',
'margin': '-0.5em 0px',
}
}).component();
const preRequisiteLearnMoreLink = view.modelBuilder.hyperlink()
.withProps({
label: loc.LEARN_MORE,
ariaLabel: loc.LEARN_MORE_ABOUT_PRE_REQS,
url: 'https://aka.ms/azuresqlmigrationextension',
}).component();
const preReqContainer = view.modelBuilder.flexContainer()
.withItems([
preRequisiteListTitle,
preRequisiteListElement,
preRequisiteLearnMoreLink])
.withLayout({ flexFlow: 'column' })
.component();
tasksContainer.addItem(migrateButton, {});
tasksContainer.addItems(
[preReqContainer],
{ CSSStyles: { 'margin-left': '20px' } });
return tasksContainer;
}
private _createTaskButton(view: azdata.ModelView, taskMetaData: IActionMetadata): azdata.Component {
const maxHeight: number = 84;
const maxWidth: number = 236;
const buttonContainer = view.modelBuilder.button().withProps({
buttonType: azdata.ButtonType.Informational,
description: taskMetaData.description,
height: maxHeight,
iconHeight: 32,
iconPath: taskMetaData.iconPath,
iconWidth: 32,
label: taskMetaData.title,
title: taskMetaData.title,
width: maxWidth,
CSSStyles: {
'border': '1px solid',
'display': 'flex',
'flex-direction': 'column',
'justify-content': 'flex-start',
'border-radius': '4px',
'transition': 'all .5s ease',
}
}).component();
this.disposables.push(
buttonContainer.onDidClick(async () => {
if (taskMetaData.command) {
await vscode.commands.executeCommand(taskMetaData.command);
}
}));
return view.modelBuilder.divContainer().withItems([buttonContainer]).component();
}
private async _createFooter(view: azdata.ModelView): Promise<azdata.Component> {
const footerContainer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'row',
width: maxWidth,
justifyContent: 'flex-start'
}).component();
const statusContainer = await this._createMigrationStatusContainer(view);
const videoLinksContainer = this._createVideoLinks(view);
footerContainer.addItem(statusContainer);
footerContainer.addItem(
videoLinksContainer,
{ CSSStyles: { 'padding-left': '8px', } });
return footerContainer;
}
private _createVideoLinks(view: azdata.ModelView): azdata.Component {
const linksContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
width: '440px',
height: '365px',
justifyContent: 'flex-start',
}).withProps({
CSSStyles: {
'border': '1px solid rgba(0, 0, 0, 0.1)',
'padding': '10px',
'overflow': 'scroll',
}
}).component();
const titleComponent = view.modelBuilder.text()
.withProps({
value: loc.HELP_TITLE,
CSSStyles: { ...styles.SECTION_HEADER_CSS }
})
.component();
linksContainer.addItems(
[titleComponent],
{ CSSStyles: { 'margin-bottom': '16px' } });
const links = [
{
title: loc.DASHBOARD_HELP_LINK_MIGRATE_USING_ADS,
description: loc.DASHBOARD_HELP_DESCRIPTION_MIGRATE_USING_ADS,
link: 'https://docs.microsoft.com/azure/dms/migration-using-azure-data-studio'
},
{
title: loc.DASHBOARD_HELP_LINK_MI_TUTORIAL,
description: loc.DASHBOARD_HELP_DESCRIPTION_MI_TUTORIAL,
link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-managed-instance-online-ads'
},
{
title: loc.DASHBOARD_HELP_LINK_VM_TUTORIAL,
description: loc.DASHBOARD_HELP_DESCRIPTION_VMTUTORIAL,
link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-to-virtual-machine-online-ads'
},
{
title: loc.DASHBOARD_HELP_LINK_DMS_GUIDE,
description: loc.DASHBOARD_HELP_DESCRIPTION_DMS_GUIDE,
link: 'https://docs.microsoft.com/data-migration/'
},
];
linksContainer.addItems(links.map(l => this._createLink(view, l)), {});
const videoLinks: IActionMetadata[] = [];
const videosContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'row',
width: maxWidth,
}).component();
videosContainer.addItems(videoLinks.map(l => this._createVideoLink(view, l)), {});
linksContainer.addItem(videosContainer);
return linksContainer;
}
private _createLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component {
const maxWidth = 400;
const labelsContainer = view.modelBuilder.flexContainer()
.withProps({
CSSStyles: {
'flex-direction': 'column',
'width': `${maxWidth}px`,
'justify-content': 'flex-start',
'margin-bottom': '12px'
}
}).component();
const linkContainer = view.modelBuilder.flexContainer()
.withProps({
CSSStyles: {
'flex-direction': 'row',
'width': `${maxWidth}px`,
'justify-content': 'flex-start',
'margin-bottom': '4px'
}
}).component();
const descriptionComponent = view.modelBuilder.text()
.withProps({
value: linkMetaData.description,
width: maxWidth,
CSSStyles: { ...styles.NOTE_CSS }
}).component();
const linkComponent = view.modelBuilder.hyperlink()
.withProps({
label: linkMetaData.title!,
url: linkMetaData.link!,
showLinkIcon: true,
CSSStyles: { ...styles.BODY_CSS }
}).component();
linkContainer.addItem(linkComponent);
labelsContainer.addItems([linkContainer, descriptionComponent]);
return labelsContainer;
}
private _createVideoLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component {
const maxWidth = 150;
const videosContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
width: maxWidth,
justifyContent: 'flex-start'
}).component();
const video1Container = view.modelBuilder.divContainer()
.withProps({
clickable: true,
width: maxWidth,
height: '100px'
}).component();
const descriptionComponent = view.modelBuilder.text()
.withProps({
value: linkMetaData.description,
width: maxWidth,
height: '50px',
CSSStyles: { ...styles.BODY_CSS }
}).component();
this.disposables.push(
video1Container.onDidClick(async () => {
if (linkMetaData.link) {
await vscode.env.openExternal(vscode.Uri.parse(linkMetaData.link));
}
}));
videosContainer.addItem(video1Container, {
CSSStyles: {
'background-image': `url(${vscode.Uri.file(<string>linkMetaData.iconPath?.light)})`,
'background-repeat': 'no-repeat',
'background-position': 'top',
'width': `${maxWidth}px`,
'height': '104px',
'background-size': `${maxWidth}px 120px`
}
});
videosContainer.addItem(descriptionComponent);
return videosContainer;
}
private _createStatusCard(
view: azdata.ModelView,
cardIconPath: IconPath,
cardTitle: string,
hasSubtext: boolean = false
): StatusCard {
const buttonWidth = '400px';
const buttonHeight = hasSubtext ? '70px' : '50px';
const statusCard = view.modelBuilder.flexContainer()
.withProps({
CSSStyles: {
'width': buttonWidth,
'height': buttonHeight,
'align-items': 'center',
}
}).component();
const statusIcon = view.modelBuilder.image()
.withProps({
iconPath: cardIconPath!.light,
iconHeight: 24,
iconWidth: 24,
height: 32,
CSSStyles: { 'margin': '0 8px' }
}).component();
const textContainer = view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
const cardTitleText = view.modelBuilder.text()
.withProps({ value: cardTitle })
.withProps({
CSSStyles: {
...styles.SECTION_HEADER_CSS,
'width': '240px',
}
}).component();
textContainer.addItem(cardTitleText);
const cardCount = view.modelBuilder.text()
.withProps({
value: '0',
CSSStyles: {
...styles.BIG_NUMBER_CSS,
'margin': '0 0 0 8px',
'text-align': 'center',
}
}).component();
let warningContainer;
let warningText;
if (hasSubtext) {
const warningIcon = view.modelBuilder.image()
.withProps({
iconPath: IconPathHelper.warning,
iconWidth: 12,
iconHeight: 12,
width: 12,
height: 18,
}).component();
const warningDescription = '';
warningText = view.modelBuilder.text()
.withProps({
value: warningDescription,
CSSStyles: {
...styles.BODY_CSS,
'padding-left': '8px',
}
}).component();
warningContainer = view.modelBuilder.flexContainer()
.withItems(
[warningIcon, warningText],
{ flex: '0 0 auto' })
.withProps({ CSSStyles: { 'align-items': 'center' } })
.component();
textContainer.addItem(warningContainer);
}
statusCard.addItems([
statusIcon,
textContainer,
cardCount,
]);
const compositeButton = view.modelBuilder.divContainer()
.withItems([statusCard])
.withProps({
ariaRole: 'button',
ariaLabel: loc.SHOW_STATUS,
clickable: true,
CSSStyles: {
'height': buttonHeight,
'margin-bottom': '16px',
'border': '1px solid',
'display': 'flex',
'flex-direction': 'column',
'justify-content': 'flex-start',
'border-radius': '4px',
'transition': 'all .5s ease',
}
}).component();
return {
container: compositeButton,
count: cardCount,
textContainer: textContainer,
warningContainer: warningContainer,
warningText: warningText
};
}
private async _createMigrationStatusContainer(view: azdata.ModelView): Promise<azdata.FlexContainer> {
const statusContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
width: '400px',
height: '365px',
justifyContent: 'flex-start',
})
.withProps({
CSSStyles: {
'border': '1px solid rgba(0, 0, 0, 0.1)',
'padding': '10px',
}
})
.component();
const statusContainerTitle = view.modelBuilder.text()
.withProps({
value: loc.DATABASE_MIGRATION_STATUS,
width: '100%',
CSSStyles: { ...styles.SECTION_HEADER_CSS }
}).component();
this._refreshButton = view.modelBuilder.button()
.withProps({
label: loc.REFRESH,
iconPath: IconPathHelper.refresh,
iconHeight: 16,
iconWidth: 16,
width: 70,
CSSStyles: { 'float': 'right' }
}).component();
const statusHeadingContainer = view.modelBuilder.flexContainer()
.withItems([
statusContainerTitle,
this._refreshButton,
]).withLayout({
alignContent: 'center',
alignItems: 'center',
flexFlow: 'row',
}).component();
this.disposables.push(
this._refreshButton.onDidClick(async (e) => {
this._refreshButton.enabled = false;
await this.refresh();
this._refreshButton.enabled = true;
}));
const buttonContainer = view.modelBuilder.flexContainer()
.withProps({
CSSStyles: {
'justify-content': 'left',
'align-iems': 'center',
},
})
.component();
buttonContainer.addItem(
await this._createServiceSelector(view));
this._selectServiceText = view.modelBuilder.text()
.withProps({
value: loc.SELECT_SERVICE_MESSAGE,
CSSStyles: {
'font-size': '12px',
'margin': '10px',
'font-weight': '350',
'text-align': 'center',
'display': 'none'
}
}).component();
const header = view.modelBuilder.flexContainer()
.withItems([statusHeadingContainer, buttonContainer])
.withLayout({ flexFlow: 'column', })
.component();
this._migrationStatusCardsContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
height: '272px',
})
.withProps({ CSSStyles: { 'overflow': 'hidden auto' } })
.component();
await this._updateSummaryStatus();
// in progress
this._inProgressMigrationButton = this._createStatusCard(
view,
IconPathHelper.inProgressMigration,
loc.MIGRATION_IN_PROGRESS);
this.disposables.push(
this._inProgressMigrationButton.container.onDidClick(
async (e) => await this.openMigrationFcn(AdsMigrationStatus.ONGOING)));
this._migrationStatusCardsContainer.addItem(
this._inProgressMigrationButton.container,
{ flex: '0 0 auto' });
// in progress warning
this._inProgressWarningMigrationButton = this._createStatusCard(
view,
IconPathHelper.inProgressMigration,
loc.MIGRATION_IN_PROGRESS,
true);
this.disposables.push(
this._inProgressWarningMigrationButton.container.onDidClick(
async (e) => await this.openMigrationFcn(AdsMigrationStatus.ONGOING)));
this._migrationStatusCardsContainer.addItem(
this._inProgressWarningMigrationButton.container,
{ flex: '0 0 auto' });
// successful
this._successfulMigrationButton = this._createStatusCard(
view,
IconPathHelper.completedMigration,
loc.MIGRATION_COMPLETED);
this.disposables.push(
this._successfulMigrationButton.container.onDidClick(
async (e) => await this.openMigrationFcn(AdsMigrationStatus.SUCCEEDED)));
this._migrationStatusCardsContainer.addItem(
this._successfulMigrationButton.container,
{ flex: '0 0 auto' });
// completing
this._completingMigrationButton = this._createStatusCard(
view,
IconPathHelper.completingCutover,
loc.MIGRATION_CUTOVER_CARD);
this.disposables.push(
this._completingMigrationButton.container.onDidClick(
async (e) => await this.openMigrationFcn(AdsMigrationStatus.COMPLETING)));
this._migrationStatusCardsContainer.addItem(
this._completingMigrationButton.container,
{ flex: '0 0 auto' });
// failed
this._failedMigrationButton = this._createStatusCard(
view,
IconPathHelper.error,
loc.MIGRATION_FAILED);
this.disposables.push(
this._failedMigrationButton.container.onDidClick(
async (e) => await this.openMigrationFcn(AdsMigrationStatus.FAILED)));
this._migrationStatusCardsContainer.addItem(
this._failedMigrationButton.container,
{ flex: '0 0 auto' });
// all migrations
this._allMigrationButton = this._createStatusCard(
view,
IconPathHelper.view,
loc.VIEW_ALL);
this.disposables.push(
this._allMigrationButton.container.onDidClick(
async (e) => await this.openMigrationFcn(AdsMigrationStatus.ALL)));
this._migrationStatusCardsContainer.addItem(
this._allMigrationButton.container,
{ flex: '0 0 auto' });
this._migrationStatusCardLoadingContainer = view.modelBuilder.loadingComponent()
.withItem(this._migrationStatusCardsContainer)
.component();
statusContainer.addItem(header, { CSSStyles: { 'margin-bottom': '10px' } });
statusContainer.addItem(this._selectServiceText, {});
statusContainer.addItem(this._migrationStatusCardLoadingContainer, {});
return statusContainer;
}
private async _createServiceSelector(view: azdata.ModelView): Promise<azdata.Component> {
const serviceContextLabel = await getSelectedServiceStatus();
this._serviceContextButton = view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.sqlMigrationService,
iconHeight: 22,
iconWidth: 22,
label: serviceContextLabel,
title: serviceContextLabel,
description: loc.MIGRATION_SERVICE_DESCRIPTION,
buttonType: azdata.ButtonType.Informational,
width: 375,
CSSStyles: { ...BUTTON_CSS },
})
.component();
this.disposables.push(
this._serviceContextButton.onDidClick(async () => {
const dialog = new SelectMigrationServiceDialog(async () => await this.onDialogClosed());
await dialog.initialize();
}));
return this._serviceContextButton;
}
private _updateStatusCard(
migrations: DatabaseMigration[],
card: StatusCard,
status: AdsMigrationStatus,
show?: boolean): void {
const list = filterMigrations(migrations, status);
const count = list?.length || 0;
card.container.display = count > 0 || show ? '' : 'none';
card.count.value = count.toString();
}
private async _updateSummaryStatus(): Promise<void> {
const serviceContext = await MigrationLocalStorage.getMigrationServiceContext();
const isContextValid = isServiceContextValid(serviceContext);
await this._selectServiceText.updateCssStyles({ 'display': isContextValid ? 'none' : 'block' });
await this._migrationStatusCardsContainer.updateCssStyles({ 'visibility': isContextValid ? 'visible' : 'hidden' });
this._refreshButton.enabled = isContextValid;
}
}

View File

@@ -0,0 +1,205 @@
/*---------------------------------------------------------------------------------------------
* 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 * as loc from '../constants/strings';
import { getSqlServerName, getMigrationStatusImage } from '../api/utils';
import { logError, TelemetryViews } from '../telemtery';
import { canCancelMigration, canCutoverMigration, canRetryMigration, getMigrationStatus, getMigrationTargetTypeEnum, isOfflineMigation } from '../constants/helper';
import { getResourceName } from '../api/azure';
import { InfoFieldSchema, infoFieldWidth, MigrationDetailsTabBase, MigrationTargetTypeName } from './migrationDetailsTabBase';
import { EmptySettingValue } from './tabBase';
import { DashboardStatusBar } from './sqlServerDashboard';
const MigrationDetailsBlobContainerTabId = 'MigrationDetailsBlobContainerTab';
export class MigrationDetailsBlobContainerTab extends MigrationDetailsTabBase<MigrationDetailsBlobContainerTab> {
private _sourceDatabaseInfoField!: InfoFieldSchema;
private _sourceDetailsInfoField!: InfoFieldSchema;
private _sourceVersionInfoField!: InfoFieldSchema;
private _targetDatabaseInfoField!: InfoFieldSchema;
private _targetServerInfoField!: InfoFieldSchema;
private _targetVersionInfoField!: InfoFieldSchema;
private _migrationStatusInfoField!: InfoFieldSchema;
private _backupLocationInfoField!: InfoFieldSchema;
private _lastAppliedBackupInfoField!: InfoFieldSchema;
private _currentRestoringFileInfoField!: InfoFieldSchema;
constructor() {
super();
this.id = MigrationDetailsBlobContainerTabId;
}
public async create(
context: vscode.ExtensionContext,
view: azdata.ModelView,
onClosedCallback: () => Promise<void>,
statusBar: DashboardStatusBar,
): Promise<MigrationDetailsBlobContainerTab> {
this.view = view;
this.context = context;
this.onClosedCallback = onClosedCallback;
this.statusBar = statusBar;
await this.initialize(this.view);
return this;
}
public async refresh(): Promise<void> {
if (this.isRefreshing || this.model?.migration === undefined) {
return;
}
this.isRefreshing = true;
this.refreshButton.enabled = false;
this.refreshLoader.loading = true;
await this.statusBar.clearError();
try {
await this.model.fetchStatus();
} catch (e) {
await this.statusBar.showError(
loc.MIGRATION_STATUS_REFRESH_ERROR,
loc.MIGRATION_STATUS_REFRESH_ERROR,
e.message);
}
const migration = this.model?.migration;
await this.cutoverButton.updateCssStyles(
{ 'display': isOfflineMigation(migration) ? 'none' : 'block' });
await this.showMigrationErrors(migration);
const sqlServerName = migration.properties.sourceServerName;
const sourceDatabaseName = migration.properties.sourceDatabaseName;
const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId);
const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!);
const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion;
const targetDatabaseName = migration.name;
const targetServerName = getResourceName(migration.properties.scope);
const targetType = getMigrationTargetTypeEnum(migration);
const targetServerVersion = MigrationTargetTypeName[targetType ?? ''];
this.databaseLabel.value = sourceDatabaseName;
this._sourceDatabaseInfoField.text.value = sourceDatabaseName;
this._sourceDetailsInfoField.text.value = sqlServerName;
this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`;
this._targetDatabaseInfoField.text.value = targetDatabaseName;
this._targetServerInfoField.text.value = targetServerName;
this._targetVersionInfoField.text.value = targetServerVersion;
this._migrationStatusInfoField.text.value = getMigrationStatus(migration) ?? EmptySettingValue;
this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migration);
const storageAccountResourceId = migration.properties.backupConfiguration?.sourceLocation?.azureBlob?.storageAccountResourceId;
const blobContainerName
= migration.properties.backupConfiguration?.sourceLocation?.azureBlob?.blobContainerName
?? migration.properties.migrationStatusDetails?.blobContainerName;
const backupLocation = storageAccountResourceId && blobContainerName
? `${storageAccountResourceId?.split('/').pop()} - ${blobContainerName}`
: blobContainerName;
this._backupLocationInfoField.text.value = backupLocation ?? EmptySettingValue;
this._lastAppliedBackupInfoField.text.value = migration.properties.migrationStatusDetails?.lastRestoredFilename ?? EmptySettingValue;
this._currentRestoringFileInfoField.text.value = this.getMigrationCurrentlyRestoringFile(migration) ?? EmptySettingValue;
this.cutoverButton.enabled = canCutoverMigration(migration);
this.cancelButton.enabled = canCancelMigration(migration);
this.retryButton.enabled = canRetryMigration(migration);
this.isRefreshing = false;
this.refreshLoader.loading = false;
this.refreshButton.enabled = true;
}
protected async initialize(view: azdata.ModelView): Promise<void> {
try {
const formItems: azdata.FormComponent<azdata.Component>[] = [
{ component: this.createMigrationToolbarContainer() },
{ component: await this.migrationInfoGrid() },
{
component: this.view.modelBuilder.separator()
.withProps({ width: '100%', CSSStyles: { 'padding': '0' } })
.component()
},
];
this.content = this.view.modelBuilder.formContainer()
.withFormItems(
formItems,
{ horizontal: false })
.withLayout({ width: '100%', padding: '0 0 0 15px' })
.withProps({ width: '100%', CSSStyles: { padding: '0 0 0 15px' } })
.component();
} catch (e) {
logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e);
}
}
protected override async migrationInfoGrid(): Promise<azdata.FlexContainer> {
const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => {
container.addItem(
infoField.flexContainer,
{ CSSStyles: { width: infoFieldWidth } });
};
const flexServer = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, '');
this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, '');
this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, '');
addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer);
addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer);
addInfoFieldToContainer(this._sourceVersionInfoField, flexServer);
const flexTarget = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, '');
this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, '');
this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, '');
addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget);
addInfoFieldToContainer(this._targetServerInfoField, flexTarget);
addInfoFieldToContainer(this._targetVersionInfoField, flexTarget);
const flexStatus = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' ');
this._backupLocationInfoField = await this.createInfoField(loc.BACKUP_LOCATION, '');
addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus);
addInfoFieldToContainer(this._backupLocationInfoField, flexStatus);
const flexFile = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._lastAppliedBackupInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, '');
this._currentRestoringFileInfoField = await this.createInfoField(loc.CURRENTLY_RESTORING_FILE, '', false);
addInfoFieldToContainer(this._lastAppliedBackupInfoField, flexFile);
addInfoFieldToContainer(this._currentRestoringFileInfoField, flexFile);
const flexInfoProps = {
flex: '0',
CSSStyles: { 'flex': '0', 'width': infoFieldWidth }
};
const flexInfo = this.view.modelBuilder.flexContainer()
.withLayout({ flexWrap: 'wrap' })
.withProps({ width: '100%' })
.component();
flexInfo.addItem(flexServer, flexInfoProps);
flexInfo.addItem(flexTarget, flexInfoProps);
flexInfo.addItem(flexStatus, flexInfoProps);
flexInfo.addItem(flexFile, flexInfoProps);
return flexInfo;
}
}

View File

@@ -0,0 +1,389 @@
/*---------------------------------------------------------------------------------------------
* 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 * as loc from '../constants/strings';
import { convertByteSizeToReadableUnit, convertIsoTimeToLocalTime, getSqlServerName, getMigrationStatusImage } from '../api/utils';
import { logError, TelemetryViews } from '../telemtery';
import * as styles from '../constants/styles';
import { canCancelMigration, canCutoverMigration, canRetryMigration, getMigrationStatus, getMigrationTargetTypeEnum, isOfflineMigation } from '../constants/helper';
import { getResourceName } from '../api/azure';
import { EmptySettingValue } from './tabBase';
import { InfoFieldSchema, infoFieldWidth, MigrationDetailsTabBase, MigrationTargetTypeName } from './migrationDetailsTabBase';
import { DashboardStatusBar } from './sqlServerDashboard';
const MigrationDetailsFileShareTabId = 'MigrationDetailsFileShareTab';
interface ActiveBackupFileSchema {
fileName: string,
type: string,
status: string,
dataUploaded: string,
copyThroughput: string,
backupStartTime: string,
firstLSN: string,
lastLSN: string
}
export class MigrationDetailsFileShareTab extends MigrationDetailsTabBase<MigrationDetailsFileShareTab> {
private _sourceDatabaseInfoField!: InfoFieldSchema;
private _sourceDetailsInfoField!: InfoFieldSchema;
private _sourceVersionInfoField!: InfoFieldSchema;
private _targetDatabaseInfoField!: InfoFieldSchema;
private _targetServerInfoField!: InfoFieldSchema;
private _targetVersionInfoField!: InfoFieldSchema;
private _migrationStatusInfoField!: InfoFieldSchema;
private _fullBackupFileOnInfoField!: InfoFieldSchema;
private _backupLocationInfoField!: InfoFieldSchema;
private _lastLSNInfoField!: InfoFieldSchema;
private _lastAppliedBackupInfoField!: InfoFieldSchema;
private _lastAppliedBackupTakenOnInfoField!: InfoFieldSchema;
private _currentRestoringFileInfoField!: InfoFieldSchema;
private _fileCount!: azdata.TextComponent;
private _fileTable!: azdata.TableComponent;
private _emptyTableFill!: azdata.FlexContainer;
constructor() {
super();
this.id = MigrationDetailsFileShareTabId;
}
public async create(
context: vscode.ExtensionContext,
view: azdata.ModelView,
onClosedCallback: () => Promise<void>,
statusBar: DashboardStatusBar): Promise<MigrationDetailsFileShareTab> {
this.view = view;
this.context = context;
this.onClosedCallback = onClosedCallback;
this.statusBar = statusBar;
await this.initialize(this.view);
return this;
}
public async refresh(): Promise<void> {
if (this.isRefreshing || this.model?.migration === undefined) {
return;
}
this.isRefreshing = true;
this.refreshButton.enabled = false;
this.refreshLoader.loading = true;
await this.statusBar.clearError();
await this._fileTable.updateProperty('data', []);
try {
await this.model.fetchStatus();
} catch (e) {
await this.statusBar.showError(
loc.MIGRATION_STATUS_REFRESH_ERROR,
loc.MIGRATION_STATUS_REFRESH_ERROR,
e.message);
}
const migration = this.model?.migration;
await this.cutoverButton.updateCssStyles(
{ 'display': isOfflineMigation(migration) ? 'none' : 'block' });
await this.showMigrationErrors(migration);
const sqlServerName = migration.properties.sourceServerName;
const sourceDatabaseName = migration.properties.sourceDatabaseName;
const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId);
const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!);
const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion;
const targetDatabaseName = migration.name;
const targetServerName = getResourceName(migration.properties.scope);
const targetType = getMigrationTargetTypeEnum(migration);
const targetServerVersion = MigrationTargetTypeName[targetType ?? ''];
let lastAppliedSSN: string;
let lastAppliedBackupFileTakenOn: string;
const tableData: ActiveBackupFileSchema[] = [];
migration.properties.migrationStatusDetails?.activeBackupSets?.forEach(
(activeBackupSet) => {
tableData.push(
...activeBackupSet.listOfBackupFiles.map(f => {
return {
fileName: f.fileName,
type: activeBackupSet.backupType,
status: f.status,
dataUploaded: `${convertByteSizeToReadableUnit(f.dataWritten)} / ${convertByteSizeToReadableUnit(f.totalSize)}`,
copyThroughput: (f.copyThroughput) ? (f.copyThroughput / 1024).toFixed(2) : EmptySettingValue,
backupStartTime: activeBackupSet.backupStartDate,
firstLSN: activeBackupSet.firstLSN,
lastLSN: activeBackupSet.lastLSN
};
})
);
if (activeBackupSet.listOfBackupFiles[0].fileName === migration.properties.migrationStatusDetails?.lastRestoredFilename) {
lastAppliedSSN = activeBackupSet.lastLSN;
lastAppliedBackupFileTakenOn = activeBackupSet.backupFinishDate;
}
});
this.databaseLabel.value = sourceDatabaseName;
this._sourceDatabaseInfoField.text.value = sourceDatabaseName;
this._sourceDetailsInfoField.text.value = sqlServerName;
this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`;
this._targetDatabaseInfoField.text.value = targetDatabaseName;
this._targetServerInfoField.text.value = targetServerName;
this._targetVersionInfoField.text.value = targetServerVersion;
this._migrationStatusInfoField.text.value = getMigrationStatus(migration) ?? EmptySettingValue;
this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migration);
this._fullBackupFileOnInfoField.text.value = migration?.properties?.migrationStatusDetails?.fullBackupSetInfo?.listOfBackupFiles[0]?.fileName! ?? EmptySettingValue;
const fileShare = migration.properties.backupConfiguration?.sourceLocation?.fileShare;
const backupLocation = fileShare?.path! ?? EmptySettingValue;
this._backupLocationInfoField.text.value = backupLocation ?? EmptySettingValue;
this._lastLSNInfoField.text.value = lastAppliedSSN! ?? EmptySettingValue;
this._lastAppliedBackupInfoField.text.value = migration.properties.migrationStatusDetails?.lastRestoredFilename ?? EmptySettingValue;
this._lastAppliedBackupTakenOnInfoField.text.value = lastAppliedBackupFileTakenOn! ? convertIsoTimeToLocalTime(lastAppliedBackupFileTakenOn).toLocaleString() : EmptySettingValue;
this._currentRestoringFileInfoField.text.value = this.getMigrationCurrentlyRestoringFile(migration) ?? EmptySettingValue;
await this._fileCount.updateCssStyles({ ...styles.SECTION_HEADER_CSS, display: 'inline' });
this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length);
if (tableData.length === 0) {
await this._emptyTableFill.updateCssStyles({ 'display': 'flex' });
this._fileTable.height = '50px';
await this._fileTable.updateProperty('data', []);
} else {
await this._emptyTableFill.updateCssStyles({ 'display': 'none' });
this._fileTable.height = '300px';
// Sorting files in descending order of backupStartTime
tableData.sort((file1, file2) => new Date(file1.backupStartTime) > new Date(file2.backupStartTime) ? - 1 : 1);
}
const data = tableData.map(row => [
row.fileName,
row.type,
row.status,
row.dataUploaded,
row.copyThroughput,
convertIsoTimeToLocalTime(row.backupStartTime).toLocaleString(),
row.firstLSN,
row.lastLSN
]) || [];
await this._fileTable.updateProperty('data', data);
this.cutoverButton.enabled = canCutoverMigration(migration);
this.cancelButton.enabled = canCancelMigration(migration);
this.retryButton.enabled = canRetryMigration(migration);
this.isRefreshing = false;
this.refreshLoader.loading = false;
this.refreshButton.enabled = true;
}
protected async initialize(view: azdata.ModelView): Promise<void> {
try {
this._fileCount = this.view.modelBuilder.text()
.withProps({
width: '500px',
CSSStyles: { ...styles.BODY_CSS }
}).component();
this._fileTable = this.view.modelBuilder.table()
.withProps({
ariaLabel: loc.ACTIVE_BACKUP_FILES,
CSSStyles: { 'padding-left': '0px', 'max-width': '1020px' },
data: [],
height: '300px',
columns: [
{
value: 'files',
name: loc.ACTIVE_BACKUP_FILES,
type: azdata.ColumnType.text,
width: 230,
},
{
value: 'type',
name: loc.TYPE,
width: 90,
type: azdata.ColumnType.text,
},
{
value: 'status',
name: loc.STATUS,
width: 60,
type: azdata.ColumnType.text,
},
{
value: 'uploaded',
name: loc.DATA_UPLOADED,
width: 120,
type: azdata.ColumnType.text,
},
{
value: 'throughput',
name: loc.COPY_THROUGHPUT,
width: 150,
type: azdata.ColumnType.text,
},
{
value: 'starttime',
name: loc.BACKUP_START_TIME,
width: 130,
type: azdata.ColumnType.text,
},
{
value: 'firstlsn',
name: loc.FIRST_LSN,
width: 120,
type: azdata.ColumnType.text,
},
{
value: 'lastlsn',
name: loc.LAST_LSN,
width: 120,
type: azdata.ColumnType.text,
}
],
}).component();
const emptyTableImage = this.view.modelBuilder.image()
.withProps({
iconPath: IconPathHelper.emptyTable,
iconHeight: '100px',
iconWidth: '100px',
height: '100px',
width: '100px',
CSSStyles: { 'text-align': 'center' }
}).component();
const emptyTableText = this.view.modelBuilder.text()
.withProps({
value: loc.EMPTY_TABLE_TEXT,
CSSStyles: {
...styles.NOTE_CSS,
'margin-top': '8px',
'text-align': 'center',
'width': '300px'
}
}).component();
this._emptyTableFill = this.view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
alignItems: 'center'
}).withItems([
emptyTableImage,
emptyTableText,
]).withProps({
width: '100%',
display: 'none'
}).component();
const formItems: azdata.FormComponent<azdata.Component>[] = [
{ component: this.createMigrationToolbarContainer() },
{ component: await this.migrationInfoGrid() },
{
component: this.view.modelBuilder.separator()
.withProps({ width: '100%', CSSStyles: { 'padding': '0' } })
.component()
},
{ component: this._fileCount },
{ component: this._fileTable },
{ component: this._emptyTableFill }
];
const formContainer = this.view.modelBuilder.formContainer()
.withFormItems(
formItems,
{ horizontal: false })
.withLayout({ width: '100%', padding: '0 0 0 15px' })
.withProps({ width: '100%', CSSStyles: { padding: '0 0 0 15px' } })
.component();
this.content = formContainer;
} catch (e) {
logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e);
}
}
protected async migrationInfoGrid(): Promise<azdata.FlexContainer> {
const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => {
container.addItem(
infoField.flexContainer,
{ CSSStyles: { width: infoFieldWidth } });
};
const flexServer = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, '');
this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, '');
this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, '');
addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer);
addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer);
addInfoFieldToContainer(this._sourceVersionInfoField, flexServer);
const flexTarget = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, '');
this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, '');
this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, '');
addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget);
addInfoFieldToContainer(this._targetServerInfoField, flexTarget);
addInfoFieldToContainer(this._targetVersionInfoField, flexTarget);
const flexStatus = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' ');
this._fullBackupFileOnInfoField = await this.createInfoField(loc.FULL_BACKUP_FILES, '', false);
this._backupLocationInfoField = await this.createInfoField(loc.BACKUP_LOCATION, '');
addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus);
addInfoFieldToContainer(this._fullBackupFileOnInfoField, flexStatus);
addInfoFieldToContainer(this._backupLocationInfoField, flexStatus);
const flexFile = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._lastLSNInfoField = await this.createInfoField(loc.LAST_APPLIED_LSN, '', false);
this._lastAppliedBackupInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, '');
this._lastAppliedBackupTakenOnInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES_TAKEN_ON, '', false);
this._currentRestoringFileInfoField = await this.createInfoField(loc.CURRENTLY_RESTORING_FILE, '', false);
addInfoFieldToContainer(this._lastLSNInfoField, flexFile);
addInfoFieldToContainer(this._lastAppliedBackupInfoField, flexFile);
addInfoFieldToContainer(this._lastAppliedBackupTakenOnInfoField, flexFile);
addInfoFieldToContainer(this._currentRestoringFileInfoField, flexFile);
const flexInfoProps = {
flex: '0',
CSSStyles: {
'flex': '0',
'width': infoFieldWidth
}
};
const flexInfo = this.view.modelBuilder.flexContainer()
.withLayout({ flexWrap: 'wrap' })
.withProps({ width: '100%' })
.component();
flexInfo.addItem(flexServer, flexInfoProps);
flexInfo.addItem(flexTarget, flexInfoProps);
flexInfo.addItem(flexStatus, flexInfoProps);
flexInfo.addItem(flexFile, flexInfoProps);
return flexInfo;
}
}

View File

@@ -0,0 +1,463 @@
/*---------------------------------------------------------------------------------------------
* 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 * as styles from '../constants/styles';
import { DatabaseMigration } 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 { MigrationTargetType } from '../models/stateMachine';
import { DashboardStatusBar } from './sqlServerDashboard';
export const infoFieldLgWidth: string = '330px';
export const infoFieldWidth: string = '250px';
const statusImageSize: number = 14;
export const MigrationTargetTypeName: loc.LookupTable<string> = {
[MigrationTargetType.SQLMI]: loc.AZURE_SQL_DATABASE_MANAGED_INSTANCE,
[MigrationTargetType.SQLVM]: loc.AZURE_SQL_DATABASE_VIRTUAL_MACHINE,
[MigrationTargetType.SQLDB]: loc.AZURE_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 onClosedCallback!: () => Promise<void>;
protected cutoverButton!: azdata.ButtonComponent;
protected refreshButton!: azdata.ButtonComponent;
protected cancelButton!: 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, onClosedCallback: () => 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();
}
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.onClosedCallback()));
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.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.onClosedCallback);
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()
.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.retryButton },
<azdata.ToolbarComponent>{ component: this.copyDatabaseMigrationDetails, toolbarSeparatorAfter: true },
<azdata.ToolbarComponent>{ component: this.newSupportRequest, toolbarSeparatorAfter: true },
<azdata.ToolbarComponent>{ component: this.refreshButton },
<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': 'column',
'padding-right': '12px'
}
}).component();
const labelComponent = this.view.modelBuilder.text()
.withProps({
value: label,
CSSStyles: {
...styles.LIGHT_LABEL_CSS,
'margin-bottom': '0',
}
}).component();
flexContainer.addItem(labelComponent);
const textComponent = this.view.modelBuilder.text()
.withProps({
value: value,
title: value,
description: value,
width: '100%',
CSSStyles: {
'font-size': '13px',
'line-height': '18px',
'margin': '4px 0 12px',
'overflow': 'hidden',
'text-overflow': 'ellipsis',
'max-width': '230px',
'display': 'inline-block',
}
}).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': '7px 3px 0 0',
'padding': '0'
}
}).component();
const iconTextComponent = this.view.modelBuilder.flexContainer()
.withItems([
iconComponent,
textComponent
]).withProps({
CSSStyles: {
'margin': '0',
'padding': '0'
},
display: 'inline-flex'
}).component();
flexContainer.addItem(iconTextComponent);
} else {
flexContainer.addItem(textComponent);
}
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);
}
}

View File

@@ -0,0 +1,567 @@
/*---------------------------------------------------------------------------------------------
* 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 * as loc from '../constants/strings';
import { getSqlServerName, getMigrationStatusImage, getPipelineStatusImage, debounce } from '../api/utils';
import { logError, TelemetryViews } from '../telemtery';
import { canCancelMigration, canCutoverMigration, canRetryMigration, formatDateTimeString, formatNumber, formatSizeBytes, formatSizeKb, formatTime, getMigrationStatus, getMigrationTargetTypeEnum, isOfflineMigation, PipelineStatusCodes } from '../constants/helper';
import { CopyProgressDetail, getResourceName } from '../api/azure';
import { InfoFieldSchema, infoFieldLgWidth, MigrationDetailsTabBase, MigrationTargetTypeName } from './migrationDetailsTabBase';
import { EmptySettingValue } from './tabBase';
import { IconPathHelper } from '../constants/iconPathHelper';
import { DashboardStatusBar } from './sqlServerDashboard';
import { EOL } from 'os';
const MigrationDetailsTableTabId = 'MigrationDetailsTableTab';
const TableColumns = {
tableName: 'tableName',
status: 'status',
dataRead: 'dataRead',
dataWritten: 'dataWritten',
rowsRead: 'rowsRead',
rowsCopied: 'rowsCopied',
copyThroughput: 'copyThroughput',
copyDuration: 'copyDuration',
parallelCopyType: 'parallelCopyType',
usedParallelCopies: 'usedParallelCopies',
copyStart: 'copyStart',
};
enum SummaryCardIndex {
TotalTables = 0,
InProgressTables = 1,
SuccessfulTables = 2,
FailedTables = 3,
CanceledTables = 4,
}
export class MigrationDetailsTableTab extends MigrationDetailsTabBase<MigrationDetailsTableTab> {
private _sourceDatabaseInfoField!: InfoFieldSchema;
private _sourceDetailsInfoField!: InfoFieldSchema;
private _sourceVersionInfoField!: InfoFieldSchema;
private _targetDatabaseInfoField!: InfoFieldSchema;
private _targetServerInfoField!: InfoFieldSchema;
private _targetVersionInfoField!: InfoFieldSchema;
private _migrationStatusInfoField!: InfoFieldSchema;
private _serverObjectsInfoField!: InfoFieldSchema;
private _tableFilterInputBox!: azdata.InputBoxComponent;
private _columnSortDropdown!: azdata.DropDownComponent;
private _columnSortCheckbox!: azdata.CheckBoxComponent;
private _progressTable!: azdata.TableComponent;
private _progressDetail: CopyProgressDetail[] = [];
constructor() {
super();
this.id = MigrationDetailsTableTabId;
}
public async create(
context: vscode.ExtensionContext,
view: azdata.ModelView,
onClosedCallback: () => Promise<void>,
statusBar: DashboardStatusBar): Promise<MigrationDetailsTableTab> {
this.view = view;
this.context = context;
this.onClosedCallback = onClosedCallback;
this.statusBar = statusBar;
await this.initialize(this.view);
return this;
}
@debounce(500)
public async refresh(): Promise<void> {
if (this.isRefreshing) {
return;
}
this.isRefreshing = true;
this.refreshButton.enabled = false;
this.refreshLoader.loading = true;
await this.statusBar.clearError();
try {
await this.model.fetchStatus();
await this._loadData();
} catch (e) {
await this.statusBar.showError(
loc.MIGRATION_STATUS_REFRESH_ERROR,
loc.MIGRATION_STATUS_REFRESH_ERROR,
e.message);
}
this.isRefreshing = false;
this.refreshLoader.loading = false;
this.refreshButton.enabled = true;
}
private async _loadData(): Promise<void> {
const migration = this.model?.migration;
await this.showMigrationErrors(this.model?.migration);
await this.cutoverButton.updateCssStyles(
{ 'display': isOfflineMigation(migration) ? 'none' : 'block' });
const sqlServerName = migration?.properties.sourceServerName;
const sourceDatabaseName = migration?.properties.sourceDatabaseName;
const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId);
const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!);
const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion;
const targetDatabaseName = migration?.name;
const targetServerName = getResourceName(migration?.properties.scope);
const targetType = getMigrationTargetTypeEnum(migration);
const targetServerVersion = MigrationTargetTypeName[targetType ?? ''];
const hashSet: loc.LookupTable<number> = {};
this._progressDetail = migration?.properties.migrationStatusDetails?.listOfCopyProgressDetails ?? [];
await this._populateTableData(hashSet);
const successCount = hashSet[PipelineStatusCodes.Succeeded] ?? 0;
const cancelledCount =
(hashSet[PipelineStatusCodes.Canceled] ?? 0) +
(hashSet[PipelineStatusCodes.Cancelled] ?? 0);
const failedCount = hashSet[PipelineStatusCodes.Failed] ?? 0;
const inProgressCount =
(hashSet[PipelineStatusCodes.Queued] ?? 0) +
(hashSet[PipelineStatusCodes.CopyFinished] ?? 0) +
(hashSet[PipelineStatusCodes.Copying] ?? 0) +
(hashSet[PipelineStatusCodes.PreparingForCopy] ?? 0) +
(hashSet[PipelineStatusCodes.RebuildingIndexes] ?? 0) +
(hashSet[PipelineStatusCodes.InProgress] ?? 0);
const totalCount = migration.properties.migrationStatusDetails?.listOfCopyProgressDetails.length ?? 0;
this._updateSummaryComponent(SummaryCardIndex.TotalTables, totalCount);
this._updateSummaryComponent(SummaryCardIndex.InProgressTables, inProgressCount);
this._updateSummaryComponent(SummaryCardIndex.SuccessfulTables, successCount);
this._updateSummaryComponent(SummaryCardIndex.FailedTables, failedCount);
this._updateSummaryComponent(SummaryCardIndex.CanceledTables, cancelledCount);
this.databaseLabel.value = sourceDatabaseName;
this._sourceDatabaseInfoField.text.value = sourceDatabaseName;
this._sourceDetailsInfoField.text.value = sqlServerName;
this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`;
this._targetDatabaseInfoField.text.value = targetDatabaseName;
this._targetServerInfoField.text.value = targetServerName;
this._targetVersionInfoField.text.value = targetServerVersion;
this._migrationStatusInfoField.text.value = getMigrationStatus(migration) ?? EmptySettingValue;
this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migration);
this._serverObjectsInfoField.text.value = totalCount.toLocaleString();
this.cutoverButton.enabled = canCutoverMigration(migration);
this.cancelButton.enabled = canCancelMigration(migration);
this.retryButton.enabled = canRetryMigration(migration);
}
private async _populateTableData(hashSet: loc.LookupTable<number> = {}): Promise<void> {
if (this._progressTable.data.length > 0) {
await this._progressTable.updateProperty('data', []);
}
// Sort table data
this._sortTableMigrations(
this._progressDetail,
(<azdata.CategoryValue>this._columnSortDropdown.value).name,
this._columnSortCheckbox.checked === true);
const data = this._progressDetail.map((d) => {
hashSet[d.status] = (hashSet[d.status] ?? 0) + 1;
return [
d.tableName,
<azdata.HyperlinkColumnCellValue>{
icon: getPipelineStatusImage(d.status),
title: loc.PipelineRunStatus[d.status] ?? d.status?.toUpperCase(),
},
formatSizeBytes(d.dataRead),
formatSizeBytes(d.dataWritten),
formatNumber(d.rowsRead),
formatNumber(d.rowsCopied),
formatSizeKb(d.copyThroughput),
formatTime((d.copyDuration ?? 0) * 1000),
loc.ParallelCopyType[d.parallelCopyType] ?? d.parallelCopyType,
d.usedParallelCopies,
formatDateTimeString(d.copyStart),
];
}) ?? [];
// Filter tableData
const filteredData = this._filterTables(data, this._tableFilterInputBox.value);
await this._progressTable.updateProperty('data', filteredData);
}
protected async initialize(view: azdata.ModelView): Promise<void> {
try {
this._progressTable = this.view.modelBuilder.table()
.withProps({
ariaLabel: loc.ACTIVE_BACKUP_FILES,
CSSStyles: {
'padding-left': '0px',
'max-width': '1111px'
},
data: [],
height: '300px',
columns: [
{
value: TableColumns.tableName,
name: loc.SQLDB_COL_TABLE_NAME,
type: azdata.ColumnType.text,
width: 170,
},
<azdata.HyperlinkColumn>{
name: loc.STATUS,
value: TableColumns.status,
width: 106,
type: azdata.ColumnType.hyperlink,
icon: IconPathHelper.inProgressMigration,
showText: true,
},
{
value: TableColumns.dataRead,
name: loc.SQLDB_COL_DATA_READ,
width: 64,
type: azdata.ColumnType.text,
},
{
value: TableColumns.dataWritten,
name: loc.SQLDB_COL_DATA_WRITTEN,
width: 77,
type: azdata.ColumnType.text,
},
{
value: TableColumns.rowsRead,
name: loc.SQLDB_COL_ROWS_READ,
width: 68,
type: azdata.ColumnType.text,
},
{
value: TableColumns.rowsCopied,
name: loc.SQLDB_COL_ROWS_COPIED,
width: 77,
type: azdata.ColumnType.text,
},
{
value: TableColumns.copyThroughput,
name: loc.SQLDB_COL_COPY_THROUGHPUT,
width: 102,
type: azdata.ColumnType.text,
},
{
value: TableColumns.copyDuration,
name: loc.SQLDB_COL_COPY_DURATION,
width: 87,
type: azdata.ColumnType.text,
},
{
value: TableColumns.parallelCopyType,
name: loc.SQLDB_COL_PARRALEL_COPY_TYPE,
width: 104,
type: azdata.ColumnType.text,
},
{
value: TableColumns.usedParallelCopies,
name: loc.SQLDB_COL_USED_PARALLEL_COPIES,
width: 116,
type: azdata.ColumnType.text,
},
{
value: TableColumns.copyStart,
name: loc.SQLDB_COL_COPY_START,
width: 140,
type: azdata.ColumnType.text,
},
],
}).component();
const formItems: azdata.FormComponent<azdata.Component>[] = [
{ component: this.createMigrationToolbarContainer() },
{ component: await this.migrationInfoGrid() },
{
component: this.view.modelBuilder.separator()
.withProps({ width: '100%', CSSStyles: { 'padding': '0' } })
.component()
},
{ component: await this._createStatusBar() },
{ component: await this._createTableFilter() },
{ component: this._progressTable },
];
this.disposables.push(
this._progressTable.onCellAction!(
async (rowState: azdata.ICellActionEventArgs) => {
const buttonState = <azdata.ICellActionEventArgs>rowState;
if (buttonState?.column === 1) {
const tableName = this._progressTable!.data[rowState.row][0] || null;
const tableProgress = this.model.migration.properties.migrationStatusDetails?.listOfCopyProgressDetails?.find(
progress => progress.tableName === tableName);
const errors = tableProgress?.errors || [];
const tableStatus = loc.PipelineRunStatus[tableProgress?.status ?? ''] ?? tableProgress?.status;
const statusMessage = loc.TABLE_MIGRATION_STATUS_LABEL(tableStatus);
const errorMessage = errors.join(EOL);
this.showDialogMessage(
loc.TABLE_MIGRATION_STATUS_TITLE,
statusMessage,
errorMessage);
}
}));
const formContainer = this.view.modelBuilder.formContainer()
.withFormItems(
formItems,
{ horizontal: false })
.withProps({ width: '100%', CSSStyles: { margin: '0 0 0 5px', padding: '0 15px 0 15px' } })
.component();
this.content = formContainer;
} catch (e) {
logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e);
}
}
private _sortTableMigrations(data: CopyProgressDetail[], columnName: string, ascending: boolean): void {
const sortDir = ascending ? -1 : 1;
switch (columnName) {
case TableColumns.tableName:
data.sort((t1, t2) => this.stringCompare(t1.tableName, t2.tableName, sortDir));
return;
case TableColumns.status:
data.sort((t1, t2) => this.stringCompare(t1.status, t2.status, sortDir));
return;
case TableColumns.dataRead:
data.sort((t1, t2) => this.numberCompare(t1.dataRead, t2.dataRead, sortDir));
return;
case TableColumns.dataWritten:
data.sort((t1, t2) => this.numberCompare(t1.dataWritten, t2.dataWritten, sortDir));
return;
case TableColumns.rowsRead:
data.sort((t1, t2) => this.numberCompare(t1.rowsRead, t2.rowsRead, sortDir));
return;
case TableColumns.rowsCopied:
data.sort((t1, t2) => this.numberCompare(t1.rowsCopied, t2.rowsCopied, sortDir));
return;
case TableColumns.copyThroughput:
data.sort((t1, t2) => this.numberCompare(t1.copyThroughput, t2.copyThroughput, sortDir));
return;
case TableColumns.copyDuration:
data.sort((t1, t2) => this.numberCompare(t1.copyDuration, t2.copyDuration, sortDir));
return;
case TableColumns.parallelCopyType:
data.sort((t1, t2) => this.stringCompare(t1.parallelCopyType, t2.parallelCopyType, sortDir));
return;
case TableColumns.usedParallelCopies:
data.sort((t1, t2) => this.numberCompare(t1.usedParallelCopies, t2.usedParallelCopies, sortDir));
return;
case TableColumns.copyStart:
data.sort((t1, t2) => this.dateCompare(t1.copyStart, t2.copyStart, sortDir));
return;
}
}
private _updateSummaryComponent(cardIndex: number, value: number): void {
const stringValue = value.toLocaleString();
const textComponent = this.summaryTextComponent[cardIndex];
textComponent.value = stringValue;
textComponent.title = stringValue;
}
private _filterTables(tables: any[], value: string | undefined): any[] {
const lcValue = value?.toLowerCase() ?? '';
return lcValue.length > 0
? tables.filter((table: string[]) =>
table.some((col: string | { title: string }) => {
return typeof (col) === 'string'
? col.toLowerCase().includes(lcValue)
: col.title?.toLowerCase().includes(lcValue);
}))
: tables;
}
private async _createTableFilter(): Promise<azdata.FlexContainer> {
this._tableFilterInputBox = this.view.modelBuilder.inputBox()
.withProps({
inputType: 'text',
maxLength: 100,
width: 268,
placeHolder: loc.FILTER_SERVER_OBJECTS_PLACEHOLDER,
ariaLabel: loc.FILTER_SERVER_OBJECTS_ARIA_LABEL,
})
.component();
this.disposables.push(
this._tableFilterInputBox.onTextChanged(
async (value) => await this._populateTableData()));
const sortLabel = this.view.modelBuilder.text()
.withProps({
value: loc.SORT_LABEL,
CSSStyles: {
'font-size': '13px',
'font-weight': '600',
'margin': '3px 0 0 0',
},
}).component();
this._columnSortDropdown = this.view.modelBuilder.dropDown()
.withProps({
editable: false,
width: 150,
CSSStyles: { 'margin-left': '5px' },
value: <azdata.CategoryValue>{ name: TableColumns.copyStart, displayName: loc.START_TIME },
values: [
<azdata.CategoryValue>{ name: TableColumns.tableName, displayName: loc.SQLDB_COL_TABLE_NAME },
<azdata.CategoryValue>{ name: TableColumns.status, displayName: loc.STATUS },
<azdata.CategoryValue>{ name: TableColumns.dataRead, displayName: loc.SQLDB_COL_DATA_READ },
<azdata.CategoryValue>{ name: TableColumns.dataWritten, displayName: loc.SQLDB_COL_DATA_WRITTEN },
<azdata.CategoryValue>{ name: TableColumns.rowsRead, displayName: loc.SQLDB_COL_ROWS_READ },
<azdata.CategoryValue>{ name: TableColumns.rowsCopied, displayName: loc.SQLDB_COL_ROWS_COPIED },
<azdata.CategoryValue>{ name: TableColumns.copyThroughput, displayName: loc.SQLDB_COL_COPY_THROUGHPUT },
<azdata.CategoryValue>{ name: TableColumns.copyDuration, displayName: loc.SQLDB_COL_COPY_DURATION },
<azdata.CategoryValue>{ name: TableColumns.parallelCopyType, displayName: loc.SQLDB_COL_PARRALEL_COPY_TYPE },
<azdata.CategoryValue>{ name: TableColumns.usedParallelCopies, displayName: loc.SQLDB_COL_USED_PARALLEL_COPIES },
<azdata.CategoryValue>{ name: TableColumns.copyStart, displayName: loc.SQLDB_COL_COPY_START },
],
})
.component();
this.disposables.push(
this._columnSortDropdown.onValueChanged(
async (value) => await this._populateTableData()));
this._columnSortCheckbox = this.view.modelBuilder.checkBox()
.withProps({
label: loc.ASCENDING_LABEL,
checked: false,
CSSStyles: { 'margin-left': '15px' },
})
.component();
this.disposables.push(
this._columnSortCheckbox.onChanged(
async (value) => await this._populateTableData()));
const columnSortContainer = this.view.modelBuilder.flexContainer()
.withItems([sortLabel, this._columnSortDropdown])
.withProps({
CSSStyles: {
'justify-content': 'left',
'align-items': 'center',
'padding': '0px',
'display': 'flex',
'flex-direction': 'row',
},
}).component();
columnSortContainer.addItem(this._columnSortCheckbox, { flex: '0 0 auto' });
const flexContainer = this.view.modelBuilder.flexContainer()
.withProps({
width: '100%',
CSSStyles: {
'justify-content': 'left',
'align-items': 'center',
'padding': '0px',
'display': 'flex',
'flex-direction': 'row',
'flex-flow': 'wrap',
},
}).component();
flexContainer.addItem(this._tableFilterInputBox, { flex: '0' });
flexContainer.addItem(columnSortContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } });
return flexContainer;
}
private async _createStatusBar(): Promise<azdata.FlexContainer> {
const serverObjectsLabel = this.view.modelBuilder.text()
.withProps({
value: loc.SERVER_OBJECTS_LABEL,
CSSStyles: {
'font-weight': '600',
'font-size': '14px',
'margin': '0 0 5px 0',
},
})
.component();
const flexContainer = this.view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'row',
flexWrap: 'wrap',
})
.component();
flexContainer.addItems([
await this.createInfoCard(loc.SERVER_OBJECTS_ALL_TABLES_LABEL, IconPathHelper.allTables),
await this.createInfoCard(loc.SERVER_OBJECTS_IN_PROGRESS_TABLES_LABEL, IconPathHelper.inProgressMigration),
await this.createInfoCard(loc.SERVER_OBJECTS_SUCCESSFUL_TABLES_LABEL, IconPathHelper.completedMigration),
await this.createInfoCard(loc.SERVER_OBJECTS_FAILED_TABLES_LABEL, IconPathHelper.error),
await this.createInfoCard(loc.SERVER_OBJECTS_CANCELLED_TABLES_LABEL, IconPathHelper.cancel)
], { flex: '0 0 auto', CSSStyles: { 'width': '168px' } });
return this.view.modelBuilder.flexContainer()
.withItems([serverObjectsLabel, flexContainer])
.withLayout({ flexFlow: 'column' })
.component();
}
protected async migrationInfoGrid(): Promise<azdata.FlexContainer> {
const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => {
container.addItem(
infoField.flexContainer,
{ CSSStyles: { width: infoFieldLgWidth } });
};
const flexServer = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, '');
this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, '');
this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, '');
addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer);
addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer);
addInfoFieldToContainer(this._sourceVersionInfoField, flexServer);
const flexTarget = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, '');
this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, '');
this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, '');
addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget);
addInfoFieldToContainer(this._targetServerInfoField, flexTarget);
addInfoFieldToContainer(this._targetVersionInfoField, flexTarget);
const flexStatus = this.view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' ');
this._serverObjectsInfoField = await this.createInfoField(loc.SERVER_OBJECTS_FIELD_LABEL, '');
addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus);
addInfoFieldToContainer(this._serverObjectsInfoField, flexStatus);
const flexInfoProps = {
flex: '0',
CSSStyles: { 'flex': '0', 'width': infoFieldLgWidth }
};
const flexInfo = this.view.modelBuilder.flexContainer()
.withLayout({ flexWrap: 'wrap' })
.withProps({ width: '100%' })
.component();
flexInfo.addItem(flexServer, flexInfoProps);
flexInfo.addItem(flexTarget, flexInfoProps);
flexInfo.addItem(flexStatus, flexInfoProps);
return flexInfo;
}
}

View File

@@ -0,0 +1,771 @@
/*---------------------------------------------------------------------------------------------
* 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 { getCurrentMigrations, getSelectedServiceStatus, MigrationLocalStorage } 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 { 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 { MigrationMode } from '../models/stateMachine';
export const MigrationsListTabId = 'MigrationsListTab';
const TableColumns = {
sourceDatabase: 'sourceDatabase',
sourceServer: 'sourceServer',
status: 'status',
mode: 'mode',
targetType: 'targetType',
targetDatabse: 'targetDatabase',
targetServer: 'TargetServer',
duration: 'duration',
startTime: 'startTime',
finishTime: 'finishTime',
};
export class MigrationsListTab extends TabBase<MigrationsListTab> {
private _searchBox!: azdata.InputBoxComponent;
private _refresh!: azdata.ButtonComponent;
private _serviceContextButton!: azdata.ButtonComponent;
private _statusDropdown!: azdata.DropDownComponent;
private _columnSortDropdown!: azdata.DropDownComponent;
private _columnSortCheckbox!: azdata.CheckBoxComponent;
private _statusTable!: azdata.TableComponent;
private _refreshLoader!: azdata.LoadingComponent;
private _filteredMigrations: DatabaseMigration[] = [];
private _openMigrationDetails!: (migration: DatabaseMigration) => Promise<void>;
private _migrations: DatabaseMigration[] = [];
constructor() {
super();
this.id = MigrationsListTabId;
}
public async create(
context: vscode.ExtensionContext,
view: azdata.ModelView,
openMigrationDetails: (migration: DatabaseMigration) => Promise<void>,
statusBar: DashboardStatusBar,
): Promise<MigrationsListTab> {
this.view = view;
this.context = context;
this._openMigrationDetails = openMigrationDetails;
this.statusBar = statusBar;
await this.initialize();
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;
}
}
public async refresh(): Promise<void> {
if (this.isRefreshing) {
return;
}
this.isRefreshing = true;
this._refresh.enabled = false;
this._refreshLoader.loading = true;
await this.statusBar.clearError();
try {
await this._statusTable.updateProperty('data', []);
this._migrations = await getCurrentMigrations();
await this._populateMigrationTable();
} catch (e) {
await this.statusBar.showError(
loc.DASHBOARD_REFRESH_MIGRATIONS_TITLE,
loc.DASHBOARD_REFRESH_MIGRATIONS_LABEL,
e.message);
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.content = this.view.modelBuilder.flexContainer()
.withItems(
[
this._createToolbar(),
await this._createSearchAndSortContainer(),
this._createStatusTable()
],
{ CSSStyles: { 'width': '100%' } }
).withLayout({
width: '100%',
flexFlow: 'column',
}).withProps({ CSSStyles: { 'padding': '0px' } })
.component();
}
private _createToolbar(): azdata.ToolbarContainer {
const toolbar = this.view.modelBuilder.toolbarContainer();
this._refresh = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.refresh,
iconHeight: 24,
iconWidth: 24,
height: 24,
label: loc.REFRESH_BUTTON_LABEL,
}).component();
this.disposables.push(
this._refresh.onDidClick(
async (e) => await this.refresh()));
this._refreshLoader = this.view.modelBuilder.loadingComponent()
.withProps({
loading: false,
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 },
]);
return toolbar.component();
}
private async _createSearchAndSortContainer(): Promise<azdata.FlexContainer> {
const serviceContextLabel = await getSelectedServiceStatus();
this._serviceContextButton = this.view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.sqlMigrationService,
iconHeight: 22,
iconWidth: 22,
label: serviceContextLabel,
title: serviceContextLabel,
description: loc.MIGRATION_SERVICE_DESCRIPTION,
buttonType: azdata.ButtonType.Informational,
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);
await dialog.initialize();
}));
this._searchBox = this.view.modelBuilder.inputBox()
.withProps({
stopEnterPropagation: true,
placeHolder: loc.SEARCH_FOR_MIGRATIONS,
width: '200px',
}).component();
this.disposables.push(
this._searchBox.onTextChanged(
async (value) => await this._populateMigrationTable()));
const searchLabel = this.view.modelBuilder.text()
.withProps({
value: loc.STATUS_LABEL,
CSSStyles: {
'font-size': '13px',
'font-weight': '600',
'margin': '3px 0 0 0',
},
}).component();
this._statusDropdown = this.view.modelBuilder.dropDown()
.withProps({
ariaLabel: loc.MIGRATION_STATUS_FILTER,
values: this._statusDropdownValues,
width: '150px'
}).component();
this.disposables.push(
this._statusDropdown.onValueChanged(
async (value) => await this._populateMigrationTable()));
const searchContainer = this.view.modelBuilder.flexContainer()
.withLayout({
alignContent: 'center',
alignItems: 'center',
}).withProps({ CSSStyles: { 'margin-left': '10px' } })
.component();
searchContainer.addItem(searchLabel, { flex: '0' });
searchContainer.addItem(this._statusDropdown, { flex: '0', CSSStyles: { 'margin-left': '5px' } });
const sortLabel = this.view.modelBuilder.text()
.withProps({
value: loc.SORT_LABEL,
CSSStyles: {
'font-size': '13px',
'font-weight': '600',
'margin': '3px 0 0 0',
},
}).component();
this._columnSortDropdown = this.view.modelBuilder.dropDown()
.withProps({
editable: false,
width: 120,
CSSStyles: { 'margin-left': '5px' },
value: <azdata.CategoryValue>{ name: TableColumns.startTime, displayName: loc.START_TIME },
values: [
<azdata.CategoryValue>{ name: TableColumns.sourceDatabase, displayName: loc.SRC_DATABASE },
<azdata.CategoryValue>{ name: TableColumns.sourceServer, displayName: loc.SRC_SERVER },
<azdata.CategoryValue>{ name: TableColumns.status, displayName: loc.STATUS_COLUMN },
<azdata.CategoryValue>{ name: TableColumns.mode, displayName: loc.MIGRATION_MODE },
<azdata.CategoryValue>{ name: TableColumns.targetType, displayName: loc.AZURE_SQL_TARGET },
<azdata.CategoryValue>{ name: TableColumns.targetDatabse, displayName: loc.TARGET_DATABASE_COLUMN },
<azdata.CategoryValue>{ name: TableColumns.targetServer, displayName: loc.TARGET_SERVER_COLUMN },
<azdata.CategoryValue>{ name: TableColumns.duration, displayName: loc.DURATION },
<azdata.CategoryValue>{ name: TableColumns.startTime, displayName: loc.START_TIME },
<azdata.CategoryValue>{ name: TableColumns.finishTime, displayName: loc.FINISH_TIME },
],
})
.component();
this.disposables.push(
this._columnSortDropdown.onValueChanged(
async (e) => await this._populateMigrationTable()));
this._columnSortCheckbox = this.view.modelBuilder.checkBox()
.withProps({
label: loc.ASCENDING_LABEL,
checked: false,
CSSStyles: { 'margin-left': '15px' },
})
.component();
this.disposables.push(
this._columnSortCheckbox.onChanged(
async (e) => await this._populateMigrationTable()));
const columnSortContainer = this.view.modelBuilder.flexContainer()
.withItems([sortLabel, this._columnSortDropdown])
.withProps({
CSSStyles: {
'justify-content': 'left',
'align-items': 'center',
'padding': '0px',
'display': 'flex',
'flex-direction': 'row',
},
}).component();
columnSortContainer.addItem(this._columnSortCheckbox, { flex: '0 0 auto' });
const flexContainer = this.view.modelBuilder.flexContainer()
.withProps({
width: '100%',
CSSStyles: {
'justify-content': 'left',
'align-items': 'center',
'padding': '0px',
'display': 'flex',
'flex-direction': 'row',
'flex-flow': 'wrap',
},
}).component();
flexContainer.addItem(this._serviceContextButton, { flex: '0', CSSStyles: { 'margin-left': '10px' } });
flexContainer.addItem(this._searchBox, { flex: '0', CSSStyles: { 'margin-left': '10px' } });
flexContainer.addItem(searchContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } });
flexContainer.addItem(columnSortContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } });
const container = this.view.modelBuilder.flexContainer()
.withProps({ width: '100%' })
.component();
container.addItem(flexContainer);
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) {
case TableColumns.sourceDatabase:
migrations.sort(
(m1, m2) => this.stringCompare(
m1.properties.sourceDatabaseName,
m2.properties.sourceDatabaseName,
sortDir));
return;
case TableColumns.sourceServer:
migrations.sort(
(m1, m2) => this.stringCompare(
m1.properties.sourceServerName,
m2.properties.sourceServerName,
sortDir));
return;
case TableColumns.status:
migrations.sort(
(m1, m2) => this.stringCompare(
getMigrationStatusWithErrors(m1),
getMigrationStatusWithErrors(m2),
sortDir));
return;
case TableColumns.mode:
migrations.sort(
(m1, m2) => this.stringCompare(
getMigrationMode(m1),
getMigrationMode(m2),
sortDir));
return;
case TableColumns.targetType:
migrations.sort(
(m1, m2) => this.stringCompare(
getMigrationTargetType(m1),
getMigrationTargetType(m2),
sortDir));
return;
case TableColumns.targetDatabse:
migrations.sort(
(m1, m2) => this.stringCompare(
getResourceName(m1.id),
getResourceName(m2.id),
sortDir));
return;
case TableColumns.targetServer:
migrations.sort(
(m1, m2) => this.stringCompare(
getResourceName(m1.properties.scope),
getResourceName(m2.properties.scope),
sortDir));
return;
case TableColumns.duration:
migrations.sort((m1, m2) => {
if (!m1.properties.startedOn) {
return sortDir;
} else if (!m2.properties.startedOn) {
return -sortDir;
}
const m1_startedOn = new Date(m1.properties.startedOn);
const m2_startedOn = new Date(m2.properties.startedOn);
const m1_endedOn = new Date(m1.properties.endedOn ?? Date.now());
const m2_endedOn = new Date(m2.properties.endedOn ?? Date.now());
const m1_duration = m1_endedOn.getTime() - m1_startedOn.getTime();
const m2_duration = m2_endedOn.getTime() - m2_startedOn.getTime();
return m1_duration > m2_duration ? -sortDir : sortDir;
});
return;
case TableColumns.startTime:
migrations.sort(
(m1, m2) => this.dateCompare(
m1.properties.startedOn,
m2.properties.startedOn,
sortDir));
return;
case TableColumns.finishTime:
migrations.sort(
(m1, m2) => this.dateCompare(
m1.properties.endedOn,
m2.properties.endedOn,
sortDir));
return;
}
}
private async _populateMigrationTable(): Promise<void> {
try {
this._filteredMigrations = filterMigrations(
this._migrations,
(<azdata.CategoryValue>this._statusDropdown.value).name,
this._searchBox.value!);
this._sortMigrations(
this._filteredMigrations,
(<azdata.CategoryValue>this._columnSortDropdown.value).name,
this._columnSortCheckbox.checked === true);
const data: any[] = this._filteredMigrations.map((migration, index) => {
return [
<azdata.HyperlinkColumnCellValue>{
icon: IconPathHelper.sqlDatabaseLogo,
title: migration.properties.sourceDatabaseName ?? EmptySettingValue,
}, // sourceDatabase
migration.properties.sourceServerName ?? EmptySettingValue, // sourceServer
<azdata.HyperlinkColumnCellValue>{
icon: getMigrationStatusImage(migration),
title: getMigrationStatusWithErrors(migration),
}, // statue
getMigrationMode(migration), // mode
getMigrationTargetType(migration), // targetType
getResourceName(migration.id), // targetDatabase
getResourceName(migration.properties.scope), // targetServer
getMigrationDuration(
migration.properties.startedOn,
migration.properties.endedOn), // duration
getMigrationTime(migration.properties.startedOn), // startTime
getMigrationTime(migration.properties.endedOn), // finishTime
<azdata.ContextMenuColumnCellValue>{
title: '',
context: migration.id,
commands: this._getMenuCommands(migration), // context menu
},
];
});
await this._statusTable.updateProperty('data', data);
} catch (e) {
await this.statusBar.showError(
loc.LOAD_MIGRATION_LIST_ERROR,
loc.LOAD_MIGRATION_LIST_ERROR,
e.message);
logError(TelemetryViews.MigrationStatusDialog, 'Error populating migrations list page', e);
}
}
private _createStatusTable(): azdata.TableComponent {
const headerCssStyles = undefined;
const rowCssStyles = undefined;
this._statusTable = this.view.modelBuilder.table()
.withProps({
ariaLabel: loc.MIGRATION_STATUS,
CSSStyles: { 'margin-left': '10px' },
data: [],
forceFitColumns: azdata.ColumnSizingMode.AutoFit,
height: '500px',
columns: [
<azdata.HyperlinkColumn>{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.SRC_DATABASE,
value: 'sourceDatabase',
width: 190,
type: azdata.ColumnType.hyperlink,
showText: true,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.SRC_SERVER,
value: 'sourceServer',
width: 190,
type: azdata.ColumnType.text,
},
<azdata.HyperlinkColumn>{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.STATUS_COLUMN,
value: 'status',
width: 120,
type: azdata.ColumnType.hyperlink,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.MIGRATION_MODE,
value: 'mode',
width: 55,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.AZURE_SQL_TARGET,
value: 'targetType',
width: 120,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.TARGET_DATABASE_COLUMN,
value: 'targetDatabase',
width: 125,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.TARGET_SERVER_COLUMN,
value: 'targetServer',
width: 125,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.DURATION,
value: 'duration',
width: 55,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.START_TIME,
value: 'startTime',
width: 115,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: loc.FINISH_TIME,
value: 'finishTime',
width: 115,
type: azdata.ColumnType.text,
},
{
cssClass: rowCssStyles,
headerCssClass: headerCssStyles,
name: '',
value: 'contextMenu',
width: 25,
type: azdata.ColumnType.contextMenu,
}
]
}).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.showDialogMessage(
loc.DATABASE_MIGRATION_STATUS_TITLE,
statusMessage,
errors);
break;
case 0:
await this._openMigrationDetails(migration);
break;
}
}));
return this._statusTable;
}
private _getMenuCommands(migration: DatabaseMigration): string[] {
const menuCommands: string[] = [];
if (getMigrationModeEnum(migration) === MigrationMode.ONLINE &&
canCutoverMigration(migration)) {
menuCommands.push(MenuCommands.Cutover);
}
menuCommands.push(...[
MenuCommands.ViewDatabase,
MenuCommands.ViewTarget,
MenuCommands.ViewService,
MenuCommands.CopyMigration]);
if (canCancelMigration(migration)) {
menuCommands.push(MenuCommands.CancelMigration);
}
return menuCommands;
}
private _statusDropdownValues: azdata.CategoryValue[] = [
{ displayName: loc.STATUS_ALL, name: AdsMigrationStatus.ALL },
{ displayName: loc.STATUS_ONGOING, name: AdsMigrationStatus.ONGOING },
{ displayName: loc.STATUS_COMPLETING, name: AdsMigrationStatus.COMPLETING },
{ displayName: loc.STATUS_SUCCEEDED, name: AdsMigrationStatus.SUCCEEDED },
{ displayName: loc.STATUS_FAILED, name: AdsMigrationStatus.FAILED }
];
}

View File

@@ -0,0 +1,148 @@
/*---------------------------------------------------------------------------------------------
* 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 * as loc from '../constants/strings';
import { AdsMigrationStatus, TabBase } from './tabBase';
import { MigrationsListTab, MigrationsListTabId } from './migrationsListTab';
import { DatabaseMigration } from '../api/azure';
import { MigrationLocalStorage } from '../models/migrationLocalStorage';
import { FileStorageType } from '../models/stateMachine';
import { MigrationDetailsTabBase } from './migrationDetailsTabBase';
import { MigrationDetailsFileShareTab } from './migrationDetailsFileShareTab';
import { MigrationDetailsBlobContainerTab } from './migrationDetailsBlobContainerTab';
import { MigrationDetailsTableTab } from './migrationDetailsTableTab';
import { DashboardStatusBar } from './sqlServerDashboard';
export const MigrationsTabId = 'MigrationsTab';
export class MigrationsTab extends TabBase<MigrationsTab> {
private _tab!: azdata.DivContainer;
private _migrationsListTab!: MigrationsListTab;
private _migrationDetailsTab!: MigrationDetailsTabBase<any>;
private _migrationDetailsFileShareTab!: MigrationDetailsTabBase<any>;
private _migrationDetailsBlobTab!: MigrationDetailsTabBase<any>;
private _migrationDetailsTableTab!: MigrationDetailsTabBase<any>;
private _selectedTabId: string | undefined = undefined;
constructor() {
super();
this.title = loc.DESKTOP_MIGRATIONS_TAB_TITLE;
this.id = MigrationsTabId;
}
public onDialogClosed = async (): Promise<void> =>
await this._migrationsListTab.onDialogClosed();
public async create(
context: vscode.ExtensionContext,
view: azdata.ModelView,
statusBar: DashboardStatusBar): Promise<MigrationsTab> {
this.context = context;
this.view = view;
this.statusBar = statusBar;
await this.initialize(view);
await this._openTab(this._migrationsListTab);
return this;
}
public async refresh(): Promise<void> {
switch (this._selectedTabId) {
case undefined:
case MigrationsListTabId:
return await this._migrationsListTab?.refresh();
default:
return await this._migrationDetailsTab?.refresh();
}
}
protected async initialize(view: azdata.ModelView): Promise<void> {
this._tab = this.view.modelBuilder.divContainer()
.withLayout({ height: '100%' })
.withProps({
CSSStyles: {
'margin': '0px',
'padding': '0px',
'width': '100%'
}
})
.component();
this._migrationsListTab = await new MigrationsListTab().create(
this.context,
this.view,
async (migration) => await this._openMigrationDetails(migration),
this.statusBar);
this.disposables.push(this._migrationsListTab);
this._migrationDetailsBlobTab = await new MigrationDetailsBlobContainerTab().create(
this.context,
this.view,
async () => await this._openMigrationsListTab(),
this.statusBar);
this.disposables.push(this._migrationDetailsBlobTab);
this._migrationDetailsFileShareTab = await new MigrationDetailsFileShareTab().create(
this.context,
this.view,
async () => await this._openMigrationsListTab(),
this.statusBar);
this.disposables.push(this._migrationDetailsFileShareTab);
this._migrationDetailsTableTab = await new MigrationDetailsTableTab().create(
this.context,
this.view,
async () => await this._openMigrationsListTab(),
this.statusBar);
this.disposables.push(this._migrationDetailsFileShareTab);
this.content = this._tab;
}
public async setMigrationFilter(filter: AdsMigrationStatus): Promise<void> {
await this._migrationsListTab?.setMigrationFilter(filter);
await this._openTab(this._migrationsListTab);
await this._migrationsListTab?.setMigrationFilter(filter);
}
private async _openMigrationDetails(migration: DatabaseMigration): Promise<void> {
switch (migration.properties.backupConfiguration?.sourceLocation?.fileStorageType) {
case FileStorageType.AzureBlob:
this._migrationDetailsTab = this._migrationDetailsBlobTab;
break;
case FileStorageType.FileShare:
this._migrationDetailsTab = this._migrationDetailsFileShareTab;
break;
case FileStorageType.None:
this._migrationDetailsTab = this._migrationDetailsTableTab;
break;
}
await this._migrationDetailsTab.setMigrationContext(
await MigrationLocalStorage.getMigrationServiceContext(),
migration);
await this._openTab(this._migrationDetailsTab);
}
private async _openMigrationsListTab(): Promise<void> {
await this.statusBar.clearError();
await this._openTab(this._migrationsListTab);
}
private async _openTab(tab: azdata.Tab): Promise<void> {
if (tab.id === this._selectedTabId) {
return;
}
this._tab.clearItems();
this._tab.addItem(tab.content);
this._selectedTabId = tab.id;
}
}

View File

@@ -5,793 +5,193 @@
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import { logError, TelemetryViews } from '../telemtery';
import * as loc from '../constants/strings';
import { IconPath, IconPathHelper } from '../constants/iconPathHelper';
import { MigrationStatusDialog } from '../dialog/migrationStatus/migrationStatusDialog';
import { AdsMigrationStatus } from '../dialog/migrationStatus/migrationStatusDialogModel';
import { filterMigrations } from '../api/utils';
import * as styles from '../constants/styles';
import * as nls from 'vscode-nls';
import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog';
import { DatabaseMigration } from '../api/azure';
import { getCurrentMigrations, getSelectedServiceStatus, isServiceContextValid, MigrationLocalStorage } from '../models/migrationLocalStorage';
const localize = nls.loadMessageBundle();
import { DashboardTab } from './dashboardTab';
import { MigrationsTab, MigrationsTabId } from './migrationsTab';
import { AdsMigrationStatus } from './tabBase';
interface IActionMetadata {
title?: string,
description?: string,
link?: string,
iconPath?: azdata.ThemedIconPath,
command?: string;
export interface DashboardStatusBar {
showError: (errorTitle: string, errorLable: string, errorDescription: string) => Promise<void>;
clearError: () => Promise<void>;
errorTitle: string;
errorLabel: string;
errorDescription: string;
}
const maxWidth = 800;
const BUTTON_CSS = {
'font-size': '13px',
'line-height': '18px',
'margin': '4px 0',
'text-align': 'left',
};
interface StatusCard {
container: azdata.DivContainer;
count: azdata.TextComponent,
textContainer?: azdata.FlexContainer,
warningContainer?: azdata.FlexContainer,
warningText?: azdata.TextComponent,
}
export class DashboardWidget {
export class DashboardWidget implements DashboardStatusBar {
private _context: vscode.ExtensionContext;
private _migrationStatusCardsContainer!: azdata.FlexContainer;
private _migrationStatusCardLoadingContainer!: azdata.LoadingComponent;
private _view!: azdata.ModelView;
private _inProgressMigrationButton!: StatusCard;
private _inProgressWarningMigrationButton!: StatusCard;
private _allMigrationButton!: StatusCard;
private _successfulMigrationButton!: StatusCard;
private _failedMigrationButton!: StatusCard;
private _completingMigrationButton!: StatusCard;
private _selectServiceText!: azdata.TextComponent;
private _serviceContextButton!: azdata.ButtonComponent;
private _refreshButton!: azdata.ButtonComponent;
private _tabs!: azdata.TabbedPanelComponent;
private _statusInfoBox!: azdata.InfoBoxComponent;
private _dashboardTab!: DashboardTab;
private _migrationsTab!: MigrationsTab;
private _disposables: vscode.Disposable[] = [];
private isRefreshing: boolean = false;
public onDialogClosed = async (): Promise<void> => {
const label = await getSelectedServiceStatus();
this._serviceContextButton.label = label;
this._serviceContextButton.title = label;
await this.refreshMigrations();
};
constructor(context: vscode.ExtensionContext) {
this._context = context;
}
public errorTitle: string = '';
public errorLabel: string = '';
public errorDescription: string = '';
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) => {
azdata.ui.registerModelViewProvider('migration-dashboard', async (view) => {
this._view = view;
const container = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column',
width: '100%',
height: '100%'
}).component();
const header = this.createHeader(view);
// Files need to have the vscode-file scheme to be loaded by ADS
const watermarkUri = vscode.Uri
.file(<string>IconPathHelper.migrationDashboardHeaderBackground.light)
.with({ scheme: 'vscode-file' });
container.addItem(header, {
CSSStyles: {
'background-image': `
url(${watermarkUri}),
linear-gradient(0deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0) 100%)
`,
'background-repeat': 'no-repeat',
'background-position': '91.06% 100%',
'margin-bottom': '20px'
}
});
const tasksContainer = await this.createTasks(view);
header.addItem(tasksContainer, {
CSSStyles: {
'width': `${maxWidth}px`,
'margin': '24px'
}
});
container.addItem(await this.createFooter(view), {
CSSStyles: {
'margin': '0 24px'
}
});
this._disposables.push(
this._view.onClosed(e => {
this._disposables.forEach(
d => { try { d.dispose(); } catch { } });
}));
await view.initializeModel(container);
await this.refreshMigrations();
const openMigrationFcn = async (filter: AdsMigrationStatus): Promise<void> => {
this._tabs.selectTab(MigrationsTabId);
await this._migrationsTab.setMigrationFilter(filter);
};
this._dashboardTab = await new DashboardTab().create(
view,
async (filter: AdsMigrationStatus) => await openMigrationFcn(filter),
this);
this._disposables.push(this._dashboardTab);
this._migrationsTab = await new MigrationsTab().create(
this._context,
view,
this);
this._disposables.push(this._migrationsTab);
this._tabs = view.modelBuilder.tabbedPanel()
.withTabs([this._dashboardTab, this._migrationsTab])
.withLayout({ alwaysShowTabs: true, orientation: azdata.TabOrientation.Horizontal })
.withProps({
CSSStyles: {
'margin': '0px',
'padding': '0px',
'width': '100%'
}
})
.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()));
const flexContainer = view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.withItems([this._statusInfoBox, this._tabs])
.component();
await view.initializeModel(flexContainer);
await this.refresh();
});
}
private createHeader(view: azdata.ModelView): azdata.FlexContainer {
const header = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column',
width: maxWidth,
}).component();
const titleComponent = view.modelBuilder.text().withProps({
value: loc.DASHBOARD_TITLE,
width: '750px',
CSSStyles: {
...styles.DASHBOARD_TITLE_CSS
}
}).component();
const descriptionComponent = view.modelBuilder.text().withProps({
value: loc.DASHBOARD_DESCRIPTION,
CSSStyles: {
...styles.NOTE_CSS
}
}).component();
header.addItems([titleComponent, descriptionComponent], {
CSSStyles: {
'width': `${maxWidth}px`,
'padding-left': '24px'
}
});
return header;
public async refresh(): Promise<void> {
void this._migrationsTab.refresh();
await this._dashboardTab.refresh();
}
private async createTasks(view: azdata.ModelView): Promise<azdata.Component> {
const tasksContainer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'row',
width: '100%',
}).component();
const migrateButtonMetadata: IActionMetadata = {
title: loc.DASHBOARD_MIGRATE_TASK_BUTTON_TITLE,
description: loc.DASHBOARD_MIGRATE_TASK_BUTTON_DESCRIPTION,
iconPath: IconPathHelper.sqlMigrationLogo,
command: 'sqlmigration.start'
};
const preRequisiteListTitle = view.modelBuilder.text().withProps({
value: loc.PRE_REQ_TITLE,
CSSStyles: {
...styles.BODY_CSS,
'margin': '0px',
}
}).component();
const migrateButton = this.createTaskButton(view, migrateButtonMetadata);
const preRequisiteListElement = view.modelBuilder.text().withProps({
value: [
loc.PRE_REQ_1,
loc.PRE_REQ_2,
loc.PRE_REQ_3
],
CSSStyles: {
...styles.SMALL_NOTE_CSS,
'padding-left': '12px',
'margin': '-0.5em 0px',
}
}).component();
const preRequisiteLearnMoreLink = view.modelBuilder.hyperlink().withProps({
label: loc.LEARN_MORE,
ariaLabel: loc.LEARN_MORE_ABOUT_PRE_REQS,
url: 'https://aka.ms/azuresqlmigrationextension',
}).component();
const preReqContainer = view.modelBuilder.flexContainer().withItems([
preRequisiteListTitle,
preRequisiteListElement,
preRequisiteLearnMoreLink
]).withLayout({
flexFlow: 'column'
}).component();
tasksContainer.addItem(migrateButton, {});
tasksContainer.addItems([preReqContainer], {
CSSStyles: {
'margin-left': '20px'
}
});
return tasksContainer;
public async onDialogClosed(): Promise<void> {
await this._dashboardTab.onDialogClosed();
await this._migrationsTab.onDialogClosed();
}
private createTaskButton(view: azdata.ModelView, taskMetaData: IActionMetadata): azdata.Component {
const maxHeight: number = 84;
const maxWidth: number = 236;
const buttonContainer = view.modelBuilder.button().withProps({
buttonType: azdata.ButtonType.Informational,
description: taskMetaData.description,
height: maxHeight,
iconHeight: 32,
iconPath: taskMetaData.iconPath,
iconWidth: 32,
label: taskMetaData.title,
title: taskMetaData.title,
width: maxWidth,
CSSStyles: {
'border': '1px solid',
'display': 'flex',
'flex-direction': 'column',
'justify-content': 'flex-start',
'border-radius': '4px',
'transition': 'all .5s ease',
}
}).component();
this._disposables.push(
buttonContainer.onDidClick(async () => {
if (taskMetaData.command) {
await vscode.commands.executeCommand(taskMetaData.command);
}
}));
return view.modelBuilder.divContainer().withItems([buttonContainer]).component();
}
private _errorDialogIsOpen: boolean = false;
public async refreshMigrations(): Promise<void> {
if (this.isRefreshing) {
protected async openErrorDialog(): Promise<void> {
if (this._errorDialogIsOpen) {
return;
}
this.isRefreshing = true;
this._migrationStatusCardLoadingContainer.loading = true;
let migrations: DatabaseMigration[] = [];
try {
migrations = await getCurrentMigrations();
} catch (e) {
logError(TelemetryViews.SqlServerDashboard, 'RefreshgMigrationFailed', e);
void vscode.window.showErrorMessage(loc.DASHBOARD_REFRESH_MIGRATIONS(e.message));
}
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();
const inProgressMigrations = filterMigrations(migrations, AdsMigrationStatus.ONGOING);
let warningCount = 0;
for (let i = 0; i < inProgressMigrations.length; i++) {
if (inProgressMigrations[i].properties.migrationFailureError?.message ||
inProgressMigrations[i].properties.migrationStatusDetails?.fileUploadBlockingErrors ||
inProgressMigrations[i].properties.migrationStatusDetails?.restoreBlockingReason) {
warningCount += 1;
}
}
if (warningCount > 0) {
this._inProgressWarningMigrationButton.warningText!.value = loc.MIGRATION_INPROGRESS_WARNING(warningCount);
this._inProgressMigrationButton.container.display = 'none';
this._inProgressWarningMigrationButton.container.display = '';
} else {
this._inProgressMigrationButton.container.display = '';
this._inProgressWarningMigrationButton.container.display = 'none';
}
await view.initializeModel(flex);
});
this._inProgressMigrationButton.count.value = inProgressMigrations.length.toString();
this._inProgressWarningMigrationButton.count.value = inProgressMigrations.length.toString();
this._updateStatusCard(migrations, this._successfulMigrationButton, AdsMigrationStatus.SUCCEEDED, true);
this._updateStatusCard(migrations, this._failedMigrationButton, AdsMigrationStatus.FAILED);
this._updateStatusCard(migrations, this._completingMigrationButton, AdsMigrationStatus.COMPLETING);
this._updateStatusCard(migrations, this._allMigrationButton, AdsMigrationStatus.ALL, true);
await this._updateSummaryStatus();
this.isRefreshing = false;
this._migrationStatusCardLoadingContainer.loading = false;
}
private _updateStatusCard(
migrations: DatabaseMigration[],
card: StatusCard,
status: AdsMigrationStatus,
show?: boolean): void {
const list = filterMigrations(migrations, status);
const count = list?.length || 0;
card.container.display = count > 0 || show ? '' : 'none';
card.count.value = count.toString();
}
private createStatusCard(
cardIconPath: IconPath,
cardTitle: string,
hasSubtext: boolean = false
): StatusCard {
const buttonWidth = '400px';
const buttonHeight = hasSubtext ? '70px' : '50px';
const statusCard = this._view.modelBuilder.flexContainer()
.withProps({
CSSStyles: {
'width': buttonWidth,
'height': buttonHeight,
'align-items': 'center',
}
}).component();
const statusIcon = this._view.modelBuilder.image()
.withProps({
iconPath: cardIconPath!.light,
iconHeight: 24,
iconWidth: 24,
height: 32,
CSSStyles: { 'margin': '0 8px' }
}).component();
const textContainer = this._view.modelBuilder.flexContainer()
.withLayout({ flexFlow: 'column' })
.component();
const cardTitleText = this._view.modelBuilder.text()
.withProps({ value: cardTitle })
.withProps({
CSSStyles: {
...styles.SECTION_HEADER_CSS,
'width': '240px',
}
}).component();
textContainer.addItem(cardTitleText);
const cardCount = this._view.modelBuilder.text().withProps({
value: '0',
CSSStyles: {
...styles.BIG_NUMBER_CSS,
'margin': '0 0 0 8px',
'text-align': 'center',
}
}).component();
let warningContainer;
let warningText;
if (hasSubtext) {
const warningIcon = this._view.modelBuilder.image()
.withProps({
iconPath: IconPathHelper.warning,
iconWidth: 12,
iconHeight: 12,
width: 12,
height: 18,
}).component();
const warningDescription = '';
warningText = this._view.modelBuilder.text().withProps({ value: warningDescription })
.withProps({
CSSStyles: {
...styles.BODY_CSS,
'padding-left': '8px',
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();
}
}).component();
this._errorDialogIsOpen = false;
}));
warningContainer = this._view.modelBuilder.flexContainer()
.withItems(
[warningIcon, warningText],
{ flex: '0 0 auto' })
.withProps({
CSSStyles: { 'align-items': 'center' }
}).component();
textContainer.addItem(warningContainer);
azdata.window.openDialog(dialog);
} catch (error) {
this._errorDialogIsOpen = false;
}
statusCard.addItems([
statusIcon,
textContainer,
cardCount,
]);
const compositeButton = this._view.modelBuilder.divContainer()
.withItems([statusCard])
.withProps({
ariaRole: 'button',
ariaLabel: loc.SHOW_STATUS,
clickable: true,
CSSStyles: {
'height': buttonHeight,
'margin-bottom': '16px',
'border': '1px solid',
'display': 'flex',
'flex-direction': 'column',
'justify-content': 'flex-start',
'border-radius': '4px',
'transition': 'all .5s ease',
}
}).component();
return {
container: compositeButton,
count: cardCount,
textContainer: textContainer,
warningContainer: warningContainer,
warningText: warningText
};
}
private async createFooter(view: azdata.ModelView): Promise<azdata.Component> {
const footerContainer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'row',
width: maxWidth,
justifyContent: 'flex-start'
}).component();
const statusContainer = await this.createMigrationStatusContainer(view);
const videoLinksContainer = this.createVideoLinks(view);
footerContainer.addItem(statusContainer);
footerContainer.addItem(videoLinksContainer, {
CSSStyles: {
'padding-left': '8px',
}
});
return footerContainer;
}
private async createMigrationStatusContainer(view: azdata.ModelView): Promise<azdata.FlexContainer> {
const statusContainer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column',
width: '400px',
height: '385px',
justifyContent: 'flex-start',
}).withProps({
CSSStyles: {
'border': '1px solid rgba(0, 0, 0, 0.1)',
'padding': '10px',
}
}).component();
const statusContainerTitle = view.modelBuilder.text()
.withProps({
value: loc.DATABASE_MIGRATION_STATUS,
width: '100%',
CSSStyles: { ...styles.SECTION_HEADER_CSS }
}).component();
this._refreshButton = view.modelBuilder.button()
.withProps({
label: loc.REFRESH,
iconPath: IconPathHelper.refresh,
iconHeight: 16,
iconWidth: 16,
width: 70,
CSSStyles: { 'float': 'right' }
}).component();
const statusHeadingContainer = view.modelBuilder.flexContainer()
.withItems([
statusContainerTitle,
this._refreshButton,
]).withLayout({
alignContent: 'center',
alignItems: 'center',
flexFlow: 'row',
}).component();
this._disposables.push(
this._refreshButton.onDidClick(async (e) => {
this._refreshButton.enabled = false;
await this.refreshMigrations();
this._refreshButton.enabled = true;
}));
const buttonContainer = view.modelBuilder.flexContainer()
.withProps({
CSSStyles: {
'justify-content': 'left',
'align-iems': 'center',
},
})
.component();
buttonContainer.addItem(
await this.createServiceSelector(this._view));
this._selectServiceText = view.modelBuilder.text()
.withProps({
value: loc.SELECT_SERVICE_MESSAGE,
CSSStyles: {
'font-size': '12px',
'margin': '10px',
'font-weight': '350',
'text-align': 'center',
'display': 'none'
}
}).component();
const header = view.modelBuilder.flexContainer()
.withItems([statusHeadingContainer, buttonContainer])
.withLayout({ flexFlow: 'column', })
.component();
this._migrationStatusCardsContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
height: '272px',
})
.withProps({ CSSStyles: { 'overflow': 'hidden auto' } })
.component();
await this._updateSummaryStatus();
// in progress
this._inProgressMigrationButton = this.createStatusCard(
IconPathHelper.inProgressMigration,
loc.MIGRATION_IN_PROGRESS);
this._disposables.push(
this._inProgressMigrationButton.container.onDidClick(async (e) => {
const dialog = new MigrationStatusDialog(
this._context,
AdsMigrationStatus.ONGOING,
this.onDialogClosed);
await dialog.initialize();
}));
this._migrationStatusCardsContainer.addItem(
this._inProgressMigrationButton.container,
{ flex: '0 0 auto' });
// in progress warning
this._inProgressWarningMigrationButton = this.createStatusCard(
IconPathHelper.inProgressMigration,
loc.MIGRATION_IN_PROGRESS,
true);
this._disposables.push(
this._inProgressWarningMigrationButton.container.onDidClick(async (e) => {
const dialog = new MigrationStatusDialog(
this._context,
AdsMigrationStatus.ONGOING,
this.onDialogClosed);
await dialog.initialize();
}));
this._migrationStatusCardsContainer.addItem(
this._inProgressWarningMigrationButton.container,
{ flex: '0 0 auto' });
// successful
this._successfulMigrationButton = this.createStatusCard(
IconPathHelper.completedMigration,
loc.MIGRATION_COMPLETED);
this._disposables.push(
this._successfulMigrationButton.container.onDidClick(async (e) => {
const dialog = new MigrationStatusDialog(
this._context,
AdsMigrationStatus.SUCCEEDED,
this.onDialogClosed);
await dialog.initialize();
}));
this._migrationStatusCardsContainer.addItem(
this._successfulMigrationButton.container,
{ flex: '0 0 auto' });
// completing
this._completingMigrationButton = this.createStatusCard(
IconPathHelper.completingCutover,
loc.MIGRATION_CUTOVER_CARD);
this._disposables.push(
this._completingMigrationButton.container.onDidClick(async (e) => {
const dialog = new MigrationStatusDialog(
this._context,
AdsMigrationStatus.COMPLETING,
this.onDialogClosed);
await dialog.initialize();
}));
this._migrationStatusCardsContainer.addItem(
this._completingMigrationButton.container,
{ flex: '0 0 auto' });
// failed
this._failedMigrationButton = this.createStatusCard(
IconPathHelper.error,
loc.MIGRATION_FAILED);
this._disposables.push(
this._failedMigrationButton.container.onDidClick(async (e) => {
const dialog = new MigrationStatusDialog(
this._context,
AdsMigrationStatus.FAILED,
this.onDialogClosed);
await dialog.initialize();
}));
this._migrationStatusCardsContainer.addItem(
this._failedMigrationButton.container,
{ flex: '0 0 auto' });
// all migrations
this._allMigrationButton = this.createStatusCard(
IconPathHelper.view,
loc.VIEW_ALL);
this._disposables.push(
this._allMigrationButton.container.onDidClick(async (e) => {
const dialog = new MigrationStatusDialog(
this._context,
AdsMigrationStatus.ALL,
this.onDialogClosed);
await dialog.initialize();
}));
this._migrationStatusCardsContainer.addItem(
this._allMigrationButton.container,
{ flex: '0 0 auto' });
this._migrationStatusCardLoadingContainer = view.modelBuilder.loadingComponent()
.withItem(this._migrationStatusCardsContainer)
.component();
statusContainer.addItem(header, { CSSStyles: { 'margin-bottom': '10px' } });
statusContainer.addItem(this._selectServiceText, {});
statusContainer.addItem(this._migrationStatusCardLoadingContainer, {});
return statusContainer;
}
private async _updateSummaryStatus(): Promise<void> {
const serviceContext = await MigrationLocalStorage.getMigrationServiceContext();
const isContextValid = isServiceContextValid(serviceContext);
await this._selectServiceText.updateCssStyles({ 'display': isContextValid ? 'none' : 'block' });
await this._migrationStatusCardsContainer.updateCssStyles({ 'visibility': isContextValid ? 'visible' : 'hidden' });
this._refreshButton.enabled = isContextValid;
}
private async createServiceSelector(view: azdata.ModelView): Promise<azdata.Component> {
const serviceContextLabel = await getSelectedServiceStatus();
this._serviceContextButton = view.modelBuilder.button()
.withProps({
iconPath: IconPathHelper.sqlMigrationService,
iconHeight: 22,
iconWidth: 22,
label: serviceContextLabel,
title: serviceContextLabel,
description: loc.MIGRATION_SERVICE_DESCRIPTION,
buttonType: azdata.ButtonType.Informational,
width: 375,
CSSStyles: { ...BUTTON_CSS },
})
.component();
this._disposables.push(
this._serviceContextButton.onDidClick(async () => {
const dialog = new SelectMigrationServiceDialog(this.onDialogClosed);
await dialog.initialize();
}));
return this._serviceContextButton;
}
private createVideoLinks(view: azdata.ModelView): azdata.Component {
const linksContainer = view.modelBuilder.flexContainer()
.withLayout({
flexFlow: 'column',
width: '440px',
height: '385px',
justifyContent: 'flex-start',
}).withProps({
CSSStyles: {
'border': '1px solid rgba(0, 0, 0, 0.1)',
'padding': '10px',
'overflow': 'scroll',
}
}).component();
const titleComponent = view.modelBuilder.text().withProps({
value: loc.HELP_TITLE,
CSSStyles: {
...styles.SECTION_HEADER_CSS
}
}).component();
linksContainer.addItems([titleComponent], {
CSSStyles: {
'margin-bottom': '16px'
}
});
const links = [
{
title: localize('sql.migration.dashboard.help.link.migrateUsingADS', 'Migrate databases using Azure Data Studio'),
description: localize('sql.migration.dashboard.help.description.migrateUsingADS', 'The Azure SQL Migration extension for Azure Data Studio provides capabilities to assess, get right-sized Azure recommendations and migrate SQL Server databases to Azure.'),
link: 'https://docs.microsoft.com/azure/dms/migration-using-azure-data-studio'
},
{
title: localize('sql.migration.dashboard.help.link.mi', 'Tutorial: Migrate to Azure SQL Managed Instance (online)'),
description: localize('sql.migration.dashboard.help.description.mi', 'A step-by-step tutorial to migrate databases from a SQL Server instance (on-premises or Azure Virtual Machines) to Azure SQL Managed Instance with minimal downtime.'),
link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-managed-instance-online-ads'
},
{
title: localize('sql.migration.dashboard.help.link.vm', 'Tutorial: Migrate to SQL Server on Azure Virtual Machines (online)'),
description: localize('sql.migration.dashboard.help.description.vm', 'A step-by-step tutorial to migrate databases from a SQL Server instance (on-premises) to SQL Server on Azure Virtual Machines with minimal downtime.'),
link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-to-virtual-machine-online-ads'
},
{
title: localize('sql.migration.dashboard.help.link.dmsGuide', 'Azure Database Migration Guides'),
description: localize('sql.migration.dashboard.help.description.dmsGuide', 'A hub of migration articles that provides step-by-step guidance for migrating and modernizing your data assets in Azure.'),
link: 'https://docs.microsoft.com/data-migration/'
},
];
linksContainer.addItems(links.map(l => this.createLink(view, l)), {});
const videoLinks: IActionMetadata[] = [];
const videosContainer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'row',
width: maxWidth,
}).component();
videosContainer.addItems(videoLinks.map(l => this.createVideoLink(view, l)), {});
linksContainer.addItem(videosContainer);
return linksContainer;
}
private createLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component {
const maxWidth = 400;
const labelsContainer = view.modelBuilder.flexContainer().withProps({
CSSStyles: {
'flex-direction': 'column',
'width': `${maxWidth}px`,
'justify-content': 'flex-start',
'margin-bottom': '12px'
}
}).component();
const linkContainer = view.modelBuilder.flexContainer().withProps({
CSSStyles: {
'flex-direction': 'row',
'width': `${maxWidth}px`,
'justify-content': 'flex-start',
'margin-bottom': '4px'
}
}).component();
const descriptionComponent = view.modelBuilder.text().withProps({
value: linkMetaData.description,
width: maxWidth,
CSSStyles: {
...styles.NOTE_CSS
}
}).component();
const linkComponent = view.modelBuilder.hyperlink().withProps({
label: linkMetaData.title!,
url: linkMetaData.link!,
showLinkIcon: true,
CSSStyles: {
...styles.BODY_CSS
}
}).component();
linkContainer.addItem(linkComponent);
labelsContainer.addItems([linkContainer, descriptionComponent]);
return labelsContainer;
}
private createVideoLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component {
const maxWidth = 150;
const videosContainer = view.modelBuilder.flexContainer().withLayout({
flexFlow: 'column',
width: maxWidth,
justifyContent: 'flex-start'
}).component();
const video1Container = view.modelBuilder.divContainer().withProps({
clickable: true,
width: maxWidth,
height: '100px'
}).component();
const descriptionComponent = view.modelBuilder.text().withProps({
value: linkMetaData.description,
width: maxWidth,
height: '50px',
CSSStyles: {
...styles.BODY_CSS
}
}).component();
this._disposables.push(
video1Container.onDidClick(async () => {
if (linkMetaData.link) {
await vscode.env.openExternal(vscode.Uri.parse(linkMetaData.link));
}
}));
videosContainer.addItem(video1Container, {
CSSStyles: {
'background-image': `url(${vscode.Uri.file(<string>linkMetaData.iconPath?.light)})`,
'background-repeat': 'no-repeat',
'background-position': 'top',
'width': `${maxWidth}px`,
'height': '104px',
'background-size': `${maxWidth}px 120px`
}
});
videosContainer.addItem(descriptionComponent);
return videosContainer;
private async _updateStatusDisplay(control: azdata.Component, visible: boolean): Promise<void> {
await control.updateCssStyles({ 'display': visible ? 'inline' : 'none' });
}
}

View File

@@ -0,0 +1,228 @@
/*---------------------------------------------------------------------------------------------
* 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 * as loc from '../constants/strings';
import { IconPathHelper } from '../constants/iconPathHelper';
import { EOL } from 'os';
import { DatabaseMigration } from '../api/azure';
import { DashboardStatusBar } from './sqlServerDashboard';
import { getSelectedServiceStatus } from '../models/migrationLocalStorage';
import { util } from 'webpack';
export const SqlMigrationExtensionId = 'microsoft.sql-migration';
export const EmptySettingValue = '-';
export enum AdsMigrationStatus {
ALL = 'all',
ONGOING = 'ongoing',
SUCCEEDED = 'succeeded',
FAILED = 'failed',
COMPLETING = 'completing'
}
export 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',
RetryMigration: 'sqlmigration.retry.migration',
StartMigration: 'sqlmigration.start',
IssueReporter: 'workbench.action.openIssueReporter',
};
export abstract class TabBase<T> implements azdata.Tab, vscode.Disposable {
public content!: azdata.Component;
public title: string = '';
public id!: string;
public icon!: azdata.IconPath | undefined;
protected context!: vscode.ExtensionContext;
protected view!: azdata.ModelView;
protected disposables: vscode.Disposable[] = [];
protected isRefreshing: boolean = false;
protected openMigrationFcn!: (status: AdsMigrationStatus) => Promise<void>;
protected statusBar!: DashboardStatusBar;
protected abstract initialize(view: azdata.ModelView): Promise<void>;
public abstract refresh(): Promise<void>;
dispose() {
this.disposables.forEach(
d => { try { d.dispose(); } catch { } });
}
protected numberCompare(number1: number | undefined, number2: number | undefined, sortDir: number): number {
if (!number1) {
return sortDir;
} else if (!number2) {
return -sortDir;
}
return util.comparators.compareNumbers(number1, number2) * -sortDir;
}
protected stringCompare(string1: string | undefined, string2: string | undefined, sortDir: number): number {
if (!string1) {
return sortDir;
} else if (!string2) {
return -sortDir;
}
return string1.localeCompare(string2) * -sortDir;
}
protected dateCompare(stringDate1: string | undefined, stringDate2: string | undefined, sortDir: number): number {
if (!stringDate1) {
return sortDir;
} else if (!stringDate2) {
return -sortDir;
}
return new Date(stringDate1) > new Date(stringDate2) ? -sortDir : sortDir;
}
protected async updateServiceContext(button: azdata.ButtonComponent): Promise<void> {
const label = await getSelectedServiceStatus();
if (button.label !== label ||
button.title !== label) {
button.label = label;
button.title = label;
await this.refresh();
}
}
protected createNewMigrationButton(): azdata.ButtonComponent {
const newMigrationButton = this.view.modelBuilder.button()
.withProps({
buttonType: azdata.ButtonType.Normal,
label: loc.DESKTOP_MIGRATION_BUTTON_LABEL,
description: loc.DESKTOP_MIGRATION_BUTTON_DESCRIPTION,
height: 24,
iconHeight: 24,
iconWidth: 24,
iconPath: IconPathHelper.addNew,
}).component();
this.disposables.push(
newMigrationButton.onDidClick(async () => {
const actionId = MenuCommands.StartMigration;
const args = {
extensionId: SqlMigrationExtensionId,
issueTitle: loc.DASHBOARD_MIGRATE_TASK_BUTTON_TITLE,
};
return await vscode.commands.executeCommand(actionId, args);
}));
return newMigrationButton;
}
protected createNewSupportRequestButton(): azdata.ButtonComponent {
const newSupportRequestButton = this.view.modelBuilder.button()
.withProps({
buttonType: azdata.ButtonType.Normal,
label: loc.DESKTOP_SUPPORT_BUTTON_LABEL,
description: loc.DESKTOP_SUPPORT_BUTTON_DESCRIPTION,
height: 24,
iconHeight: 24,
iconWidth: 24,
iconPath: IconPathHelper.newSupportRequest,
}).component();
this.disposables.push(
newSupportRequestButton.onDidClick(async () => {
await vscode.env.openExternal(vscode.Uri.parse(
`https://portal.azure.com/#blade/Microsoft_Azure_Support/HelpAndSupportBlade/newsupportrequest`));
}));
return newSupportRequestButton;
}
protected createFeedbackButton(): azdata.ButtonComponent {
const feedbackButton = this.view.modelBuilder.button()
.withProps({
buttonType: azdata.ButtonType.Normal,
label: loc.DESKTOP_FEEDBACK_BUTTON_LABEL,
description: loc.DESKTOP_FEEDBACK_BUTTON_DESCRIPTION,
height: 24,
iconHeight: 24,
iconWidth: 24,
iconPath: IconPathHelper.sendFeedback,
}).component();
this.disposables.push(
feedbackButton.onDidClick(async () => {
const actionId = MenuCommands.IssueReporter;
const args = {
extensionId: SqlMigrationExtensionId,
issueTitle: loc.FEEDBACK_ISSUE_TITLE,
};
return await vscode.commands.executeCommand(actionId, args);
}));
return feedbackButton;
}
protected getMigrationErrors(migration: DatabaseMigration): string {
const errors = [];
errors.push(migration.properties.provisioningError);
errors.push(migration.properties.migrationFailureError?.message);
errors.push(...migration.properties.migrationStatusDetails?.fileUploadBlockingErrors ?? []);
errors.push(migration.properties.migrationStatusDetails?.restoreBlockingReason);
// remove undefined and duplicate error entries
return errors
.filter((e, i, arr) => e !== undefined && i === arr.indexOf(e))
.join(EOL);
}
protected showDialogMessage(
title: string,
statusMessage: string,
errorMessage: string,
): void {
const tab = azdata.window.createTab(title);
tab.registerContent(async (view) => {
const flex = view.modelBuilder.flexContainer()
.withItems([
view.modelBuilder.text()
.withProps({ value: statusMessage })
.component(),
])
.withLayout({
flexFlow: 'column',
width: 420,
})
.withProps({ CSSStyles: { 'margin': '0 15px' } })
.component();
if (errorMessage.length > 0) {
flex.addItem(
view.modelBuilder.inputBox()
.withProps({
value: errorMessage,
readOnly: true,
multiline: true,
inputType: 'text',
height: 100,
CSSStyles: { 'overflow': 'hidden auto' },
})
.component()
);
}
await view.initializeModel(flex);
});
const dialog = azdata.window.createModelViewDialog(
title,
'messageDialog',
450,
'normal');
dialog.content = [tab];
dialog.okButton.hidden = true;
dialog.cancelButton.focused = true;
dialog.cancelButton.label = loc.CLOSE;
azdata.window.openDialog(dialog);
}
}