diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index a436830c8a..abd498ad6a 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -807,7 +807,9 @@ export function sortResourceArrayByName(resourceArray: SortableAzureResources[]) export function getMigrationTargetId(migration: DatabaseMigration): string { // `${targetServerId}/providers/Microsoft.DataMigration/databaseMigrations/${targetDatabaseName}?api-version=${DMSV2_API_VERSION}` const paths = migration.id.split('/providers/Microsoft.DataMigration/', 1); - return paths[0]; + return paths?.length > 0 + ? paths[0] + : ''; } export function getMigrationTargetName(migration: DatabaseMigration): string { diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 9905c8575a..ecb549313f 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -667,21 +667,24 @@ export const SELECT_RESOURCE_GROUP_PROMPT = localize('sql.migration.blob.resourc export const SELECT_STORAGE_ACCOUNT = localize('sql.migration.blob.storageAccount.select', "Select a storage account value first."); export const SELECT_BLOB_CONTAINER = localize('sql.migration.blob.container.select', "Select a blob container value first."); + +export const MISSING_TABLE_NAME_COLUMN = localize('sql.migration.missing.table.name.column', "Table name"); export function SELECT_DATABASE_TABLES_TITLE(targetDatabaseName: string): string { return localize('sql.migration.table.select.label', "Select tables for {0}", targetDatabaseName); } export const TABLE_SELECTION_EDIT = localize('sql.migration.table.selection.edit', "Edit"); export function TABLE_SELECTION_COUNT(selectedCount: number, rowCount: number): string { - return localize('sql.migration.table.selection.count', "{0} of {1}", selectedCount, rowCount); + return localize('sql.migration.table.selection.count', "{0} of {1}", formatNumber(selectedCount), formatNumber(rowCount)); } export function TABLE_SELECTED_COUNT(selectedCount: number, rowCount: number): string { - return localize('sql.migration.table.selected.count', "{0} of {1} tables selected", selectedCount, rowCount); + return localize('sql.migration.table.selected.count', "{0} of {1} tables selected", formatNumber(selectedCount), formatNumber(rowCount)); } export function MISSING_TARGET_TABLES_COUNT(tables: number): string { - return localize('sql.migration.table.missing.count', "Missing target tables excluded from list: {0}", tables); + return localize('sql.migration.table.missing.count', "Tables missing on target: {0}", formatNumber(tables)); } -export const DATABASE_MISSING_TABLES = localize('sql.migration.database.missing.tables', "0 tables found."); +export const SELECT_TABLES_FOR_MIGRATION = localize('sql.migration.select.migration.tables', "Select tables for migration"); +export const DATABASE_MISSING_TABLES = localize('sql.migration.database.missing.tables', "0 tables found on source database."); export const DATABASE_LOADING_TABLES = localize('sql.migration.database.loading.tables', "Loading tables list..."); export const TABLE_SELECTION_FILTER = localize('sql.migration.table.selection.filter', "Filter tables"); export const TABLE_SELECTION_UPDATE_BUTTON = localize('sql.migration.table.selection.update.button', "Update"); @@ -932,7 +935,9 @@ export const AZURE_STORAGE_ACCOUNT_TO_UPLOAD_BACKUPS = localize('sql.migration.a export const SHIR = localize('sql.migration.shir', "Self-hosted integration runtime node"); export const DATABASE_TO_BE_MIGRATED = localize('sql.migration.database.to.be.migrated', "Database to be migrated"); export function COUNT_DATABASES(count: number): string { - return (count === 1) ? localize('sql.migration.count.database.single', "{0} database", count) : localize('sql.migration.count.database.multiple', "{0} databases", count); + return (count === 1) + ? localize('sql.migration.count.database.single', "{0} database", count) + : localize('sql.migration.count.database.multiple', "{0} databases", formatNumber(count)); } export function TOTAL_TABLES_SELECTED(selected: number, total: number): string { return localize('total.tables.selected.of.total', "{0} of {1}", formatNumber(selected), formatNumber(total)); diff --git a/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts b/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts index 843514f221..4c77c44008 100644 --- a/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts +++ b/extensions/sql-migration/src/dialog/createSqlMigrationService/createSqlMigrationServiceDialog.ts @@ -401,7 +401,11 @@ export class CreateSqlMigrationServiceDialog { constants.RESOURCE_GROUP_NOT_FOUND); const selectedResourceGroupValue = this.migrationServiceResourceGroupDropdown.values.find(v => v.name.toLowerCase() === this._resourceGroupPreset.toLowerCase()); - this.migrationServiceResourceGroupDropdown.value = (selectedResourceGroupValue) ? selectedResourceGroupValue : this.migrationServiceResourceGroupDropdown.values[0]; + this.migrationServiceResourceGroupDropdown.value = (selectedResourceGroupValue) + ? selectedResourceGroupValue + : this.migrationServiceResourceGroupDropdown.values?.length > 0 + ? this.migrationServiceResourceGroupDropdown.values[0] + : ''; } finally { this.migrationServiceResourceGroupDropdown.loading = false; } diff --git a/extensions/sql-migration/src/dialog/tableMigrationSelection/tableMigrationSelectionDialog.ts b/extensions/sql-migration/src/dialog/tableMigrationSelection/tableMigrationSelectionDialog.ts index 2de936d79b..bd4f67d80d 100644 --- a/extensions/sql-migration/src/dialog/tableMigrationSelection/tableMigrationSelectionDialog.ts +++ b/extensions/sql-migration/src/dialog/tableMigrationSelection/tableMigrationSelectionDialog.ts @@ -9,6 +9,8 @@ import * as constants from '../../constants/strings'; import { AzureSqlDatabaseServer } from '../../api/azure'; import { collectSourceDatabaseTableInfo, collectTargetDatabaseTableInfo, TableInfo } from '../../api/sqlUtils'; import { MigrationStateModel } from '../../models/stateMachine'; +import { IconPathHelper } from '../../constants/iconPathHelper'; +import { Tab } from 'azdata'; import { updateControlDisplay } from '../../api/utils'; const DialogName = 'TableMigrationSelection'; @@ -16,10 +18,11 @@ const DialogName = 'TableMigrationSelection'; export class TableMigrationSelectionDialog { private _dialog: azdata.window.Dialog | undefined; private _headingText!: azdata.TextComponent; - private _missingTablesText!: azdata.TextComponent; + private _refreshButton!: azdata.ButtonComponent; private _filterInputBox!: azdata.InputBoxComponent; private _tableSelectionTable!: azdata.TableComponent; - private _tableLoader!: azdata.LoadingComponent; + private _missingTargetTablesTable!: azdata.TableComponent; + private _refreshLoader!: azdata.LoadingComponent; private _disposables: vscode.Disposable[] = []; private _isOpen: boolean = false; private _model: MigrationStateModel; @@ -28,6 +31,9 @@ export class TableMigrationSelectionDialog { private _targetTableMap!: Map; private _onSaveCallback: () => Promise; private _missingTableCount: number = 0; + private _selectableTablesTab!: Tab; + private _missingTablesTab!: Tab; + private _tabs!: azdata.TabbedPanelComponent; constructor( model: MigrationStateModel, @@ -41,7 +47,12 @@ export class TableMigrationSelectionDialog { private async _loadData(): Promise { try { - this._tableLoader.loading = true; + this._refreshLoader.loading = true; + + this._updateRowSelection(); + await updateControlDisplay(this._tableSelectionTable, false); + await updateControlDisplay(this._missingTargetTablesTable, false); + const targetDatabaseInfo = this._model._sourceTargetMapping.get(this._sourceDatabaseName); if (targetDatabaseInfo) { const sourceTableList: TableInfo[] = await collectSourceDatabaseTableInfo( @@ -86,6 +97,7 @@ export class TableMigrationSelectionDialog { this._tableSelectionMap.set(table.tableName, tableInfo); }); } + this._dialog!.message = { text: '', level: azdata.window.MessageLevel.Information }; } catch (error) { this._dialog!.message = { text: constants.DATABASE_TABLE_CONNECTION_ERROR, @@ -93,13 +105,16 @@ export class TableMigrationSelectionDialog { level: azdata.window.MessageLevel.Error }; } finally { - this._tableLoader.loading = false; + this._refreshLoader.loading = false; + await updateControlDisplay(this._tableSelectionTable, true, 'flex'); + await updateControlDisplay(this._missingTargetTablesTable, true, 'flex'); await this._loadControls(); } } private async _loadControls(): Promise { const data: any[][] = []; + const missingData: any[][] = []; const filterText = this._filterInputBox.value ?? ''; const selectedItems: number[] = []; let tableRow = 0; @@ -125,67 +140,45 @@ export class TableMigrationSelectionDialog { } tableRow++; + } else { + this._missingTableCount++; + missingData.push([sourceTable.tableName]); } - - this._missingTableCount += targetTable ? 0 : 1; } }); await this._tableSelectionTable.updateProperty('data', data); this._tableSelectionTable.selectedRows = selectedItems; - await this._updateRowSelection(); + await this._missingTargetTablesTable.updateProperty('data', missingData); + + this._updateRowSelection(); + if (this._missingTableCount > 0 && this._tabs.items.length === 1) { + this._tabs.updateTabs([this._selectableTablesTab, this._missingTablesTab]); + } } private async _initializeDialog(dialog: azdata.window.Dialog): Promise { - dialog.registerContent(async (view) => { - this._filterInputBox = view.modelBuilder.inputBox() - .withProps({ - inputType: 'search', - placeHolder: constants.TABLE_SELECTION_FILTER, - width: 268, - }).component(); + const tab = azdata.window.createTab(''); + tab.registerContent(async (view) => { - this._disposables.push( - this._filterInputBox.onTextChanged( - async e => await this._loadControls())); - - this._headingText = view.modelBuilder.text() - .withProps({ value: constants.DATABASE_LOADING_TABLES }) + this._tabs = view.modelBuilder.tabbedPanel() + .withTabs([]) .component(); - this._missingTablesText = view.modelBuilder.text() - .withProps({ display: 'none' }) - .component(); + await this._createSelectableTablesTab(view); + await this._createMissingTablesTab(view); - this._tableSelectionTable = await this._createSelectionTable(view); - this._tableLoader = view.modelBuilder.loadingComponent() - .withItem(this._tableSelectionTable) - .withProps({ - loading: false, - loadingText: constants.DATABASE_TABLE_DATA_LOADING - }).component(); - - const flex = view.modelBuilder.flexContainer() - .withItems([ - this._filterInputBox, - this._headingText, - this._missingTablesText, - this._tableLoader], - { flex: '0 0 auto' }) - .withProps({ CSSStyles: { 'margin': '0 0 0 15px' } }) - .withLayout({ - flexFlow: 'column', - height: '100%', - width: 565, - }).component(); + this._tabs.updateTabs([this._selectableTablesTab]); this._disposables.push( view.onClosed(e => this._disposables.forEach( d => { try { d.dispose(); } catch { } }))); - await view.initializeModel(flex); + await view.initializeModel(this._tabs); await this._loadData(); }); + + dialog.content = [tab]; } public async openDialog(dialogTitle: string) { @@ -194,7 +187,7 @@ export class TableMigrationSelectionDialog { this._dialog = azdata.window.createModelViewDialog( dialogTitle, DialogName, - 600); + 600, undefined, undefined, false); this._dialog.okButton.label = constants.TABLE_SELECTION_UPDATE_BUTTON; this._dialog.okButton.position = 'left'; @@ -214,13 +207,105 @@ export class TableMigrationSelectionDialog { } } - private async _createSelectionTable(view: azdata.ModelView): Promise { + private async _createSelectableTablesTab(view: azdata.ModelView): Promise { + this._headingText = view.modelBuilder.text() + .withProps({ value: constants.DATABASE_LOADING_TABLES }) + .component(); + + this._filterInputBox = view.modelBuilder.inputBox() + .withProps({ + inputType: 'search', + placeHolder: constants.TABLE_SELECTION_FILTER, + width: 268, + }).component(); + + this._disposables.push( + this._filterInputBox.onTextChanged( + async e => await this._loadControls())); + + this._refreshButton = view.modelBuilder.button() + .withProps({ + buttonType: azdata.ButtonType.Normal, + iconHeight: 16, + iconWidth: 16, + iconPath: IconPathHelper.refresh, + label: constants.DATABASE_TABLE_REFRESH_LABEL, + width: 70, + CSSStyles: { 'margin': '5px 0 0 15px' }, + }) + .component(); + this._disposables.push( + this._refreshButton.onDidClick( + async e => await this._loadData())); + + this._refreshLoader = view.modelBuilder.loadingComponent() + .withItem(this._refreshButton) + .withProps({ + loading: false, + CSSStyles: { 'height': '8px', 'margin': '5px 0 0 15px' } + }) + .component(); + + const flexTopRow = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + flexWrap: 'wrap', + }) + .component(); + flexTopRow.addItem(this._filterInputBox, { flex: '0 0 auto' }); + flexTopRow.addItem(this._refreshLoader, { flex: '0 0 auto' }); + + this._tableSelectionTable = this._createSelectionTable(view); + + const flex = view.modelBuilder.flexContainer() + .withItems([ + flexTopRow, + this._headingText, + this._tableSelectionTable], + { flex: '0 0 auto' }) + .withProps({ CSSStyles: { 'margin': '10px 0 0 15px' } }) + .withLayout({ + flexFlow: 'column', + height: '100%', + width: 550, + }).component(); + + this._selectableTablesTab = { + content: flex, + id: 'tableSelectionTab', + title: constants.SELECT_TABLES_FOR_MIGRATION, + }; + } + + private async _createMissingTablesTab(view: azdata.ModelView): Promise { + this._missingTargetTablesTable = this._createMissingTablesTable(view); + + const flex = view.modelBuilder.flexContainer() + .withItems( + [this._missingTargetTablesTable], + { flex: '0 0 auto' }) + .withProps({ CSSStyles: { 'margin': '10px 0 0 15px' } }) + .withLayout({ + flexFlow: 'column', + height: '100%', + width: 550, + }).component(); + + this._missingTablesTab = { + content: flex, + id: 'missingTablesTab', + title: constants.MISSING_TARGET_TABLES_COUNT(this._missingTableCount), + }; + } + + private _createSelectionTable(view: azdata.ModelView): azdata.TableComponent { const cssClass = 'no-borders'; const table = view.modelBuilder.table() .withProps({ data: [], - width: 565, + width: 550, height: '600px', + display: 'flex', forceFitColumns: azdata.ColumnSizingMode.ForceFit, columns: [ { @@ -236,7 +321,7 @@ export class TableMigrationSelectionDialog { name: constants.TABLE_SELECTION_TABLENAME_COLUMN, value: 'tableName', type: azdata.ColumnType.text, - width: 300, + width: 285, cssClass: cssClass, headerCssClass: cssClass, }, @@ -275,23 +360,45 @@ export class TableMigrationSelectionDialog { } }); - await this._updateRowSelection(); + this._updateRowSelection(); })); return table; } - private async _updateRowSelection(): Promise { - this._headingText.value = this._tableSelectionTable.data.length > 0 - ? constants.TABLE_SELECTED_COUNT( - this._tableSelectionTable.selectedRows?.length ?? 0, - this._tableSelectionTable.data.length) - : this._tableLoader.loading - ? constants.DATABASE_LOADING_TABLES + private _createMissingTablesTable(view: azdata.ModelView): azdata.TableComponent { + const cssClass = 'no-borders'; + const table = view.modelBuilder.table() + .withProps({ + data: [], + width: 550, + height: '600px', + display: 'flex', + forceFitColumns: azdata.ColumnSizingMode.ForceFit, + columns: [{ + name: constants.MISSING_TABLE_NAME_COLUMN, + value: 'tableName', + type: azdata.ColumnType.text, + cssClass: cssClass, + headerCssClass: cssClass, + }], + }) + .withValidation(() => true) + .component(); + + return table; + } + + private _updateRowSelection(): void { + this._headingText.value = this._refreshLoader.loading + ? constants.DATABASE_LOADING_TABLES + : this._tableSelectionTable.data?.length > 0 + ? constants.TABLE_SELECTED_COUNT( + this._tableSelectionTable.selectedRows?.length ?? 0, + this._tableSelectionTable.data?.length ?? 0) : constants.DATABASE_MISSING_TABLES; - this._missingTablesText.value = constants.MISSING_TARGET_TABLES_COUNT(this._missingTableCount); - await updateControlDisplay(this._missingTablesText, this._missingTableCount > 0); + this._missingTablesTab.title = constants.MISSING_TARGET_TABLES_COUNT(this._missingTableCount); } private async _save(): Promise { @@ -303,7 +410,7 @@ export class TableMigrationSelectionDialog { selectedRows.forEach(rowIndex => { const tableRow = this._tableSelectionTable.data[rowIndex]; const tableName = tableRow.length > 1 - ? this._tableSelectionTable.data[rowIndex][1] as string + ? tableRow[1] as string : ''; const tableInfo = this._tableSelectionMap.get(tableName); if (tableInfo) { diff --git a/extensions/sql-migration/src/telemetry.ts b/extensions/sql-migration/src/telemetry.ts index 1e24252a21..49960192c4 100644 --- a/extensions/sql-migration/src/telemetry.ts +++ b/extensions/sql-migration/src/telemetry.ts @@ -97,12 +97,15 @@ export function sendSqlMigrationActionEvent(telemetryView: TelemetryViews, telem } export function getTelemetryProps(migrationStateModel: MigrationStateModel): TelemetryEventProperties { + const tenantId = migrationStateModel._azureAccount?.properties?.tenants?.length > 0 + ? migrationStateModel._azureAccount?.properties?.tenants[0]?.id + : ''; return { 'sessionId': migrationStateModel._sessionId, 'subscriptionId': migrationStateModel._targetSubscription?.id, 'resourceGroup': migrationStateModel._resourceGroup?.name, 'targetType': migrationStateModel._targetType, - 'tenantId': migrationStateModel._azureAccount?.properties?.tenants[0]?.id, + 'tenantId': tenantId, }; } diff --git a/extensions/sql-migration/src/wizard/databaseBackupPage.ts b/extensions/sql-migration/src/wizard/databaseBackupPage.ts index f9744c1800..53c23f7b4d 100644 --- a/extensions/sql-migration/src/wizard/databaseBackupPage.ts +++ b/extensions/sql-migration/src/wizard/databaseBackupPage.ts @@ -897,14 +897,16 @@ export class DatabaseBackupPage extends MigrationWizardPage { this._sqlSourceUsernameInput.value = username; this._sqlSourcePassword.value = (await getSourceConnectionCredentials()).password; - this._windowsUserAccountText.value = - this.migrationStateModel._databaseBackup.networkShares[0]?.windowsUser - ?? this.migrationStateModel.savedInfo?.networkShares[0]?.windowsUser - ?? ''; - this._passwordText.value = - this.migrationStateModel._databaseBackup.networkShares[0]?.password - ?? this.migrationStateModel.savedInfo?.networkShares[0]?.password - ?? ''; + const networkShares = this.migrationStateModel._databaseBackup?.networkShares?.length > 0 + ? this.migrationStateModel._databaseBackup?.networkShares + : this.migrationStateModel.savedInfo?.networkShares ?? []; + + const networkShare = networkShares?.length > 0 + ? networkShares[0] + : undefined; + + this._windowsUserAccountText.value = networkShare?.windowsUser ?? ''; + this._passwordText.value = networkShare?.password ?? ''; this._networkShareTargetDatabaseNames = []; this._networkShareLocations = []; @@ -1379,13 +1381,15 @@ export class DatabaseBackupPage extends MigrationWizardPage { break; case NetworkContainerType.NETWORK_SHARE: // All network share migrations use the same storage account - const storageAccount = this.migrationStateModel._databaseBackup.networkShares[0]?.storageAccount; - const storageKey = (await getStorageAccountAccessKeys( - this.migrationStateModel._azureAccount, - this.migrationStateModel._databaseBackup.subscription, - storageAccount)).keyName1; - for (let i = 0; i < this.migrationStateModel._databaseBackup.networkShares.length; i++) { - this.migrationStateModel._databaseBackup.networkShares[i].storageKey = storageKey; + if (this.migrationStateModel._databaseBackup.networkShares?.length > 0) { + const storageAccount = this.migrationStateModel._databaseBackup.networkShares[0]?.storageAccount; + const storageKey = (await getStorageAccountAccessKeys( + this.migrationStateModel._azureAccount, + this.migrationStateModel._databaseBackup.subscription, + storageAccount)).keyName1; + for (let i = 0; i < this.migrationStateModel._databaseBackup.networkShares.length; i++) { + this.migrationStateModel._databaseBackup.networkShares[i].storageKey = storageKey; + } } break; } diff --git a/extensions/sql-migration/src/wizard/summaryPage.ts b/extensions/sql-migration/src/wizard/summaryPage.ts index 99800bc2bd..e17ecca35f 100644 --- a/extensions/sql-migration/src/wizard/summaryPage.ts +++ b/extensions/sql-migration/src/wizard/summaryPage.ts @@ -6,7 +6,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { MigrationWizardPage } from '../models/migrationWizardPage'; -import { MigrationMode, MigrationStateModel, NetworkContainerType, StateChangeEvent } from '../models/stateMachine'; +import { MigrationMode, MigrationStateModel, NetworkContainerType, NetworkShare, StateChangeEvent } from '../models/stateMachine'; import * as constants from '../constants/strings'; import { createHeadingTextComponent, createInformationRow, createLabelTextComponent } from './wizardController'; import { getResourceGroupFromId } from '../api/azure'; @@ -185,7 +185,10 @@ export class SummaryPage extends MigrationWizardPage { .withLayout({ flexFlow: 'column' }) .component(); - const networkShare = this.migrationStateModel._databaseBackup.networkShares[0]; + const networkShare = this.migrationStateModel._databaseBackup.networkShares?.length > 0 + ? this.migrationStateModel._databaseBackup.networkShares[0] + : {}; + switch (this.migrationStateModel._databaseBackup.networkContainerType) { case NetworkContainerType.NETWORK_SHARE: flexContainer.addItems([