diff --git a/extensions/sql-migration/images/cutover.svg b/extensions/sql-migration/images/cutover.svg new file mode 100644 index 0000000000..1a361e628f --- /dev/null +++ b/extensions/sql-migration/images/cutover.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-migration/images/inProgress.svg b/extensions/sql-migration/images/inProgress.svg new file mode 100644 index 0000000000..6ce7a6f03c --- /dev/null +++ b/extensions/sql-migration/images/inProgress.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/extensions/sql-migration/images/notStarted.svg b/extensions/sql-migration/images/notStarted.svg new file mode 100644 index 0000000000..53d53b5821 --- /dev/null +++ b/extensions/sql-migration/images/notStarted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extensions/sql-migration/images/sqlMI.svg b/extensions/sql-migration/images/sqlMI.svg new file mode 100644 index 0000000000..e68cd269e4 --- /dev/null +++ b/extensions/sql-migration/images/sqlMI.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-migration/images/sqlMiImportHelpThumbnail.svg b/extensions/sql-migration/images/sqlMiImportHelpThumbnail.svg deleted file mode 100644 index 94fbb8932b..0000000000 --- a/extensions/sql-migration/images/sqlMiImportHelpThumbnail.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/extensions/sql-migration/images/sqlMiVideoThumbnail.svg b/extensions/sql-migration/images/sqlMiVideoThumbnail.svg new file mode 100644 index 0000000000..afa2c63237 --- /dev/null +++ b/extensions/sql-migration/images/sqlMiVideoThumbnail.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-migration/images/sqlVmImportHelpThumbnail.svg b/extensions/sql-migration/images/sqlVmImportHelpThumbnail.svg deleted file mode 100644 index 1f1f586e01..0000000000 --- a/extensions/sql-migration/images/sqlVmImportHelpThumbnail.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/extensions/sql-migration/images/sqlVmVideoThumbnail.svg b/extensions/sql-migration/images/sqlVmVideoThumbnail.svg new file mode 100644 index 0000000000..a9e0ea180c --- /dev/null +++ b/extensions/sql-migration/images/sqlVmVideoThumbnail.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-migration/images/succeeded.svg b/extensions/sql-migration/images/succeeded.svg new file mode 100644 index 0000000000..1f8df787fc --- /dev/null +++ b/extensions/sql-migration/images/succeeded.svg @@ -0,0 +1 @@ +success_complete diff --git a/extensions/sql-migration/package.json b/extensions/sql-migration/package.json index 7d9654830d..2bc0d4f8fa 100644 --- a/extensions/sql-migration/package.json +++ b/extensions/sql-migration/package.json @@ -14,7 +14,6 @@ "activationEvents": [ "onDashboardOpen", "onCommand:sqlmigration.start", - "onCommand:sqlmigration.testDialog", "onCommand:sqlmigration.openNotebooks" ], "main": "./out/main", @@ -32,11 +31,6 @@ "title": "SQL Migration Start", "category": "SQL Migration" }, - { - "command": "sqlmigration.testDialog", - "title": "SQL Migration test dialog", - "category": "SQL Migration" - }, { "command": "sqlmigration.openNotebooks", "title": "%migration-notebook-command-title%", @@ -60,8 +54,8 @@ "name": "", "row": 0, "col": 1, - "rowspan": 5, - "colspan": 5, + "rowspan": 2.5, + "colspan": 3.5, "widget": { "modelview": { "id": "migration.dashboard" diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index b5903e027a..0d78849598 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as azurecore from 'azurecore'; import { azureResource } from 'azureResource'; +import * as loc from '../constants/strings'; async function getAzureCoreAPI(): Promise { const api = (await vscode.extensions.getExtension(azurecore.extension.name)?.activate()) as azurecore.IExtension; @@ -82,7 +83,7 @@ export async function getBlobContainers(account: azdata.Account, subscription: S return blobContainers!; } -export async function getMigrationController(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, controllerName: string): Promise { +export async function getMigrationController(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, controllerName: string): Promise { const api = await getAzureCoreAPI(); const host = `https://${regionName}.management.azure.com`; const path = `/subscriptions/${subscription.id}/resourceGroups/${resourceGroupName}/providers/Microsoft.DataMigration/Controllers/${controllerName}?api-version=2020-09-01-preview`; @@ -93,7 +94,7 @@ export async function getMigrationController(account: azdata.Account, subscripti return response.response.data; } -export async function getMigrationControllers(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string): Promise { +export async function getMigrationControllers(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string): Promise { const api = await getAzureCoreAPI(); const host = `https://${regionName}.management.azure.com`; const path = `/subscriptions/${subscription.id}/providers/Microsoft.DataMigration/Controllers?api-version=2020-09-01-preview`; @@ -101,10 +102,11 @@ export async function getMigrationControllers(account: azdata.Account, subscript if (response.errors.length > 0) { throw new Error(response.errors.toString()); } + sortResourceArrayByName(response.response.data.value); return response.response.data.value; } -export async function createMigrationController(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, controllerName: string): Promise { +export async function createMigrationController(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, controllerName: string): Promise { const api = await getAzureCoreAPI(); const host = `https://${regionName}.management.azure.com`; const path = `/subscriptions/${subscription.id}/resourceGroups/${resourceGroupName}/providers/Microsoft.DataMigration/Controllers/${controllerName}?api-version=2020-09-01-preview`; @@ -157,10 +159,10 @@ export async function getMigrationControllerMonitoringData(account: azdata.Accou return response.response.data; } -export async function startDatabaseMigration(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, managedInstance: string, migrationControllerName: string, requestBody: StartDatabaseMigrationRequest): Promise { +export async function startDatabaseMigration(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, managedInstance: string, targetDatabaseName: string, requestBody: StartDatabaseMigrationRequest): Promise { const api = await getAzureCoreAPI(); const host = `https://${regionName}.management.azure.com`; - const path = `/subscriptions/${subscription.id}/resourceGroups/${resourceGroupName}/providers/Microsoft.Sql/managedInstances/${managedInstance}/providers/Microsoft.DataMigration/databaseMigrations/${migrationControllerName}?api-version=2020-09-01-preview`; + const path = `/subscriptions/${subscription.id}/resourceGroups/${resourceGroupName}/providers/Microsoft.Sql/managedInstances/${managedInstance}/providers/Microsoft.DataMigration/databaseMigrations/${targetDatabaseName}?api-version=2020-09-01-preview`; const response = await api.makeAzureRestRequest(account, subscription, path, azurecore.HttpRequestMethod.PUT, requestBody, true, host); if (response.errors.length > 0) { throw new Error(response.errors.toString()); @@ -171,7 +173,18 @@ export async function startDatabaseMigration(account: azdata.Account, subscripti }; } -export async function getMigrationStatus(account: azdata.Account, subscription: Subscription, migration: DatabaseMigration): Promise { +export async function getDatabaseMigration(account: azdata.Account, subscription: Subscription, regionName: string, migrationId: string): Promise { + const api = await getAzureCoreAPI(); + const host = `https://${regionName}.management.azure.com`; + const path = `${migrationId}?api-version=2020-09-01-preview`; + const response = await api.makeAzureRestRequest(account, subscription, path, azurecore.HttpRequestMethod.GET, undefined, true, host); + if (response.errors.length > 0) { + throw new Error(response.errors.toString()); + } + return response.response.data; +} + +export async function getMigrationStatus(account: azdata.Account, subscription: Subscription, migration: DatabaseMigration): Promise { const api = await getAzureCoreAPI(); const host = `https://eastus2euap.management.azure.com`; const path = `${migration.id}?$expand=MigrationStatusDetails&api-version=2020-09-01-preview`; @@ -179,9 +192,29 @@ export async function getMigrationStatus(account: azdata.Account, subscription: if (response.errors.length > 0) { throw new Error(response.errors.toString()); } - return { - result: response.response.data - }; + return response.response.data; +} + +export async function listMigrationsByController(account: azdata.Account, subscription: Subscription, controller: SqlMigrationController): Promise { + const api = await getAzureCoreAPI(); + const host = `https://eastus2euap.management.azure.com`; + const path = `${controller.id}/listMigrations?$expand=MigrationStatusDetails&api-version=2020-09-01-preview`; + const response = await api.makeAzureRestRequest(account, subscription, path, azurecore.HttpRequestMethod.GET, undefined, true, host); + if (response.errors.length > 0) { + throw new Error(response.errors.toString()); + } + return response.response.data.value; +} + +export async function startMigrationCutover(account: azdata.Account, subscription: Subscription, migrationStatus: DatabaseMigration): Promise { + const api = await getAzureCoreAPI(); + const host = `https://eastus2euap.management.azure.com`; + const path = `${migrationStatus.id}/operations/${migrationStatus.properties.migrationOperationId}/cutover?api-version=2020-09-01-preview`; + const response = await api.makeAzureRestRequest(account, subscription, path, azurecore.HttpRequestMethod.POST, undefined, true, host); + if (response.errors.length > 0) { + throw new Error(response.errors.toString()); + } + return response.response.data.value; } /** @@ -190,13 +223,13 @@ export async function getMigrationStatus(account: azdata.Account, subscription: export function getMigrationControllerRegions(): azdata.CategoryValue[] { return [ { - displayName: 'East US EUAP', + displayName: loc.EASTUS2EUAP, name: 'eastus2euap' } ]; } -type SortableAzureResources = AzureProduct | azureResource.FileShare | azureResource.BlobContainer | azureResource.AzureResourceSubscription; +type SortableAzureResources = AzureProduct | azureResource.FileShare | azureResource.BlobContainer | azureResource.AzureResourceSubscription | SqlMigrationController; function sortResourceArrayByName(resourceArray: SortableAzureResources[]): void { if (!resourceArray) { return; @@ -222,7 +255,7 @@ export interface MigrationControllerProperties { isProvisioned?: boolean; } -export interface MigrationController { +export interface SqlMigrationController { properties: MigrationControllerProperties; location: string; id: string; @@ -285,19 +318,105 @@ export interface StartDatabaseMigrationRequest { } } -export interface DatabaseMigration { - properties: { - name: string, - provisioningState: string, - sourceDatabaseName: string, - migrationOperationId: string, - }, - id: string, - name: string, - type: string -} - export interface StartDatabaseMigrationResponse { status: number, databaseMigration: DatabaseMigration } + +export interface DatabaseMigration { + properties: DatabaseMigrationProperties; + id: string; + name: string; + type: string; +} +export interface DatabaseMigrationProperties { + scope: string; + provisioningState: string; + migrationStatus: string; + migrationStatusDetails?: MigrationStatusDetails; + sourceSqlConnection: SqlConnectionInfo; + sourceDatabaseName: string; + targetDatabaseCollation: string; + migrationController: string; + migrationOperationId: string; + backupConfiguration: BackupConfiguration; + autoCutoverConfiguration: AutoCutoverConfiguration; + migrationFailureError: ErrorInfo; +} +export interface MigrationStatusDetails { + migrationState: string; + startedOn: string; + endedOn: string; + fullBackupSetInfo: BackupSetInfo; + lastRestoredBackupSetInfo: BackupSetInfo; + activeBackupSets: BackupSetInfo[]; + blobContainerName: string; + isFullBackupRestored: boolean; + restoreBlockingReason: string; + fileUploadBlockingErrors: string[]; + currentRestoringFileName: string; + lastRestoredFilename: string; +} + +export interface SqlConnectionInfo { + dataSource: string; + authentication: string; + username: string; + password: string; + encryptConnection: string; + trustServerCertificate: string; +} + +export interface BackupConfiguration { + sourceLocation: SourceLocation; + targetLocation: TargetLocation; +} + +export interface AutoCutoverConfiguration { + lastBackupName: string; +} + +export interface ErrorInfo { + code: string; + message: string; +} + +export interface BackupSetInfo { + backupSetId: string; + firstLSN: string; + lastLSN: string; + backupType: string; + listOfBackupFiles: BackupFileInfo[]; + backupStartDate: string; + backupFinishDate: string; + isBackupRestored: boolean; + backupSize: number; + compressedBackupSize: number; +} + +export interface SourceLocation { + fileShare: DatabaseMigrationFileShare; + azureBlob: DatabaseMigrationAzureBlob; +} + +export interface TargetLocation { + storageAccountResourceId: string; + accountKey: string; +} + +export interface BackupFileInfo { + fileName: string; + status: string; +} + +export interface DatabaseMigrationFileShare { + path: string; + username: string; + password: string; +} + +export interface DatabaseMigrationAzureBlob { + storageAccountResourceId: string; + accountKey: string; + blobContainerName: string; +} diff --git a/extensions/sql-migration/src/constants/iconPathHelper.ts b/extensions/sql-migration/src/constants/iconPathHelper.ts index 64c3eec683..15e9de31f7 100644 --- a/extensions/sql-migration/src/constants/iconPathHelper.ts +++ b/extensions/sql-migration/src/constants/iconPathHelper.ts @@ -13,10 +13,14 @@ export interface IconPath { export class IconPathHelper { public static copy: IconPath; public static refresh: IconPath; - public static sqlMiImportHelpThumbnail: IconPath; - public static sqlVmImportHelpThumbnail: IconPath; - public static migrationDashboardHeaderBackground: IconPath; + public static cutover: IconPath; public static sqlMigrationLogo: IconPath; + public static sqlMiVideoThumbnail: IconPath; + public static sqlVmVideoThumbnail: IconPath; + public static migrationDashboardHeaderBackground: IconPath; + public static inProgressMigration: IconPath; + public static completedMigration: IconPath; + public static notStartedMigration: IconPath; public static setExtensionContext(context: vscode.ExtensionContext) { IconPathHelper.copy = { @@ -27,13 +31,13 @@ export class IconPathHelper { light: context.asAbsolutePath('images/refresh.svg'), dark: context.asAbsolutePath('images/refresh.svg') }; - IconPathHelper.sqlMiImportHelpThumbnail = { - light: context.asAbsolutePath('images/sqlMiImportHelpThumbnail.svg'), - dark: context.asAbsolutePath('images/sqlMiImportHelpThumbnail.svg') + IconPathHelper.sqlMiVideoThumbnail = { + light: context.asAbsolutePath('images/sqlMiVideoThumbnail.svg'), + dark: context.asAbsolutePath('images/sqlMiVideoThumbnail.svg') }; - IconPathHelper.sqlVmImportHelpThumbnail = { - light: context.asAbsolutePath('images/sqlVmImportHelpThumbnail.svg'), - dark: context.asAbsolutePath('images/sqlVmImportHelpThumbnail.svg') + IconPathHelper.sqlVmVideoThumbnail = { + light: context.asAbsolutePath('images/sqlVmVideoThumbnail.svg'), + dark: context.asAbsolutePath('images/sqlVmVideoThumbnail.svg') }; IconPathHelper.migrationDashboardHeaderBackground = { light: context.asAbsolutePath('images/background.svg'), @@ -43,5 +47,21 @@ export class IconPathHelper { light: context.asAbsolutePath('images/migration.svg'), dark: context.asAbsolutePath('images/migration.svg') }; + IconPathHelper.inProgressMigration = { + light: context.asAbsolutePath('images/inProgress.svg'), + dark: context.asAbsolutePath('images/inProgress.svg') + }; + IconPathHelper.completedMigration = { + light: context.asAbsolutePath('images/succeeded.svg'), + dark: context.asAbsolutePath('images/succeeded.svg') + }; + IconPathHelper.notStartedMigration = { + light: context.asAbsolutePath('images/notStarted.svg'), + dark: context.asAbsolutePath('images/notStarted.svg') + }; + IconPathHelper.cutover = { + light: context.asAbsolutePath('images/cutover.svg'), + dark: context.asAbsolutePath('images/cutover.svg') + }; } } diff --git a/extensions/sql-migration/src/constants/notebookPathHelper.ts b/extensions/sql-migration/src/constants/notebookPathHelper.ts index 7b1e84da08..b901307c3a 100644 --- a/extensions/sql-migration/src/constants/notebookPathHelper.ts +++ b/extensions/sql-migration/src/constants/notebookPathHelper.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as loc from '../models/strings'; +import * as loc from './strings'; export class NotebookPathHelper { private static context: vscode.ExtensionContext; diff --git a/extensions/sql-migration/src/models/strings.ts b/extensions/sql-migration/src/constants/strings.ts similarity index 84% rename from extensions/sql-migration/src/models/strings.ts rename to extensions/sql-migration/src/constants/strings.ts index ae5e6bf17d..6c885f7f35 100644 --- a/extensions/sql-migration/src/models/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -192,4 +192,52 @@ export const PRE_REQ_TITLE = localize('sql.migration.pre.req.title', "Things you export const PRE_REQ_1 = localize('sql.migration.pre.req.1', "Azure account details"); export const PRE_REQ_2 = localize('sql.migration.pre.req.2', "Azure SQL Managed Instance or SQL Server on Azure Virtual Machine"); export const PRE_REQ_3 = localize('sql.migration.pre.req.3', "Backup location details"); +export const MIGRATION_IN_PROGRESS = localize('sql.migration.migration.in.progress', "Migration in progress"); +export const LOG_SHIPPING_IN_PROGRESS = localize('sql.migration.log.shipping.in.progress', "Log shipping in progress"); +export const MIGRATION_COMPLETED = localize('sql.migration.migration.completed', "Migration completed"); +export const SUCCESSFULLY_MIGRATED_TO_AZURE_SQL = localize('sql.migration.successfully.migrated.to.azure.sql', "Successfully migrated to Azure SQL"); +export const MIGRATION_NOT_STARTED = localize('sql.migration.migration.not.started', "Migration not started"); +export const CHOOSE_TO_MIGRATE_TO_AZURE_SQL = localize('sql.migration.choose.to.migrate.to.azure.sql', "Choose to migrate to Azure SQL"); + +// Azure APIs +export const EASTUS2EUAP = localize('sql.migration.eastus2euap', 'East US 2 EUAP'); + + +//Migration cutover dialog +export const MIGRATION_CUTOVER = localize('sql.migration.cutover', "Migration cutover"); +export const SOURCE_SERVER = localize('sql.migration.source.server', "Source server"); +export const SOURCE_VERSION = localize('sql.migration.source.version', "Source version"); +export const TARGET_SERVER = localize('sql.migration.target.server', "Target server"); +export const TARGET_VERSION = localize('sql.migration.target.version', "Target version"); +export const MIGRATION_STATUS = localize('sql.migration.migration.status', "Migration status"); +export const FULL_BACKUP_FILES = localize('sql.migration.full.backup.files', "Full backup files(s)"); +export const LAST_APPLIED_LSN = localize('sql.migration.last.applied.lsn', "Last applied LSN"); +export const LAST_APPLIED_BACKUP_FILES = localize('sql.migration.last.applied.backup.files', "Last applied backup file(s)"); +export const LAST_APPLIED_BACKUP_FILES_TAKEN_ON = localize('sql.migration.last.applied.files.taken.on', "Last applied backup file(s) taken on"); +export const ACTIVE_BACKUP_FILES = localize('sql.migration.active.backup.files', "Active Backup file(s)"); +export const STATUS = localize('sql.migration.status', "Status"); +export const BACKUP_START_TIME = localize('sql.migration.backup.start.time', "Backup start time"); +export const FIRST_LSN = localize('sql.migration.first.lsn', "First LSN"); +export const LAST_LSN = localize('sql.migration.last.LSN', "Last LSN"); +export const CANNOT_START_CUTOVER_ERROR = localize('sql.migration.cannot.start.cutover.error', "Cannot start the cutover process until all the migrations are done. Click refresh to fetch the latest file status"); +export const AZURE_SQL_DATABASE_MANAGED_INSTANCE = localize('sql.migration.azure.sql.database.managed.instance', "Azure SQL Database Managed Instance"); +export const AZURE_SQL_DATABASE_VIRTUAL_MACHINE = localize('sql.migration.azure.sql.database.virtual.machine', "Azure SQL Database Virtual Machine"); + +export function ACTIVE_BACKUP_FILES_ITEMS(fileCount: number) { + if (fileCount === 1) { + return localize('sql.migration.active.backup.files.items', "Active Backup files (1 item)"); + } else { + return localize('sql.migration.active.backup.files.multiple.items', "Active Backup files ({0} items)", fileCount); + } +} + + +//Migration status dialog +export const SEARCH_FOR_MIGRATIONS = localize('sql.migration.search.for.migration', "Search for migrations"); +export const ONLINE = localize('sql.migration.online', "Online"); +export const DATABASE = localize('sql.migration.database', "Database"); +export const TARGET_AZURE_SQL_INSTANCE_NAME = localize('sql.migration.target.azure.sql.instance.name', "Target Azure SQL Instance Name"); +export const CUTOVER_TYPE = localize('sql.migration.cutover.type', "Cutover type"); +export const START_TIME = localize('sql.migration.start.time', "Start Time"); +export const FINISH_TIME = localize('sql.migration.finish.time', "Finish Time"); diff --git a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts index ce8e81e8c3..dd0c5c010f 100644 --- a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts +++ b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts @@ -5,9 +5,12 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; -import { MigrationLocalStorage } from '../models/migrationLocalStorage'; -import * as loc from '../models/strings'; -import { IconPathHelper } from '../constants/iconPathHelper'; +import { MigrationContext, MigrationLocalStorage } from '../models/migrationLocalStorage'; +import * as loc from '../constants/strings'; +import { IconPath, IconPathHelper } from '../constants/iconPathHelper'; +import { getDatabaseMigration } from '../api/azure'; +import { MigrationStatusDialog } from '../dialog/migrationStatus/migrationStatusDialog'; +import { MigrationCategory } from '../dialog/migrationStatus/migrationStatusDialogModel'; interface IActionMetadata { title?: string, @@ -22,6 +25,7 @@ const maxWidth = 800; export class DashboardWidget { private _migrationStatusCardsContainer!: azdata.FlexContainer; + private _migrationStatusCardLoadingContainer!: azdata.LoadingComponent; private _view!: azdata.ModelView; constructor() { @@ -30,47 +34,36 @@ export class DashboardWidget { public register(): void { 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); - - const tasksContainer = await this.createTasks(view); - container.addItem(header, { CSSStyles: { 'background-image': `url(${vscode.Uri.file(IconPathHelper.migrationDashboardHeaderBackground.light)})`, - 'width': '1100px', - 'height': '300px', + 'width': '870px', + 'height': '260px', 'background-size': '100%', } }); + + const tasksContainer = await this.createTasks(view); header.addItem(tasksContainer, { CSSStyles: { 'width': `${maxWidth}px`, 'height': '150px', } }); - - header.addItem(await this.createFooter(view), { + container.addItem(await this.createFooter(view), { CSSStyles: { 'margin-top': '20px' } }); - - const mainContainer = view.modelBuilder.flexContainer() - .withLayout({ - flexFlow: 'column', - width: '100%', - height: '100%', - position: 'absolute' - }).component(); - mainContainer.addItem(container, { - CSSStyles: { 'padding-top': '25px', 'padding-left': '5px' } - }); - await view.initializeModel(mainContainer); + await view.initializeModel(container); this.refreshMigrations(); }); @@ -85,12 +78,14 @@ export class DashboardWidget { value: loc.DASHBOARD_TITLE, CSSStyles: { 'font-size': '36px', + 'margin-bottom': '5px', } }).component(); const descComponent = view.modelBuilder.text().withProps({ value: loc.DASHBOARD_DESCRIPTION, CSSStyles: { 'font-size': '12px', + 'margin-top': '10px', } }).component(); header.addItems([titleComponent, descComponent], { @@ -99,7 +94,6 @@ export class DashboardWidget { 'padding-left': '20px' } }); - return header; } @@ -135,7 +129,9 @@ export class DashboardWidget { const preRequisiteListElement = view.modelBuilder.text().withProps({ value: points, CSSStyles: { - 'padding-left': '15px' + 'padding-left': '15px', + 'margin-bottom': '5px', + 'margin-top': '10px' } }).component(); @@ -160,7 +156,6 @@ export class DashboardWidget { } }); - tasksContainer.addItem(migrateButton, { CSSStyles: { 'margin-top': '20px', @@ -199,19 +194,172 @@ export class DashboardWidget { } private async refreshMigrations(): Promise { + this._migrationStatusCardLoadingContainer.loading = true; this._migrationStatusCardsContainer.clearItems(); - const currentConnection = (await azdata.connection.getCurrentConnection()); - const getMigrations = MigrationLocalStorage.getMigrations(currentConnection); - getMigrations.forEach((migration) => { - const button = this._view.modelBuilder.button().withProps({ - label: `Migration to ${migration.targetManagedInstance.name} using controller ${migration.migrationContext.name}` - }).component(); - button.onDidClick(async (e) => { + try { + const migrationStatus = await this.getMigrations(); + + const inProgressMigrations = migrationStatus.filter((value) => { + const status = value.migrationContext.properties.migrationStatus; + return status === 'InProgress' || status === 'Creating' || status === 'Completing'; + }); + const inProgressMigrationButton = this.createStatusCard( + IconPathHelper.inProgressMigration, + loc.MIGRATION_IN_PROGRESS, + loc.LOG_SHIPPING_IN_PROGRESS, + inProgressMigrations.length + ); + inProgressMigrationButton.onDidClick((e) => { + const dialog = new MigrationStatusDialog(migrationStatus, MigrationCategory.ONGOING); + dialog.initialize(); + }); + this._migrationStatusCardsContainer.addItem(inProgressMigrationButton); + + const successfulMigration = migrationStatus.filter((value) => { + const status = value.migrationContext.properties.migrationStatus; + return status === 'Succeeded'; + }); + const successfulMigrationButton = this.createStatusCard( + IconPathHelper.completedMigration, + loc.MIGRATION_COMPLETED, + loc.SUCCESSFULLY_MIGRATED_TO_AZURE_SQL, + successfulMigration.length + ); + successfulMigrationButton.onDidClick((e) => { + const dialog = new MigrationStatusDialog(migrationStatus, MigrationCategory.SUCCEEDED); + dialog.initialize(); }); this._migrationStatusCardsContainer.addItem( - button + successfulMigrationButton ); + + const currentConnection = (await azdata.connection.getCurrentConnection()); + const localMigrations = MigrationLocalStorage.getMigrationsBySourceConnections(currentConnection); + const migrationDatabases = new Set( + localMigrations.filter((value) => { + + }).map((value) => { + return value.migrationContext.properties.sourceDatabaseName; + })); + const serverDatabases = await azdata.connection.listDatabases(currentConnection.connectionId); + const notStartedMigrationCard = this.createStatusCard( + IconPathHelper.notStartedMigration, + loc.MIGRATION_NOT_STARTED, + loc.CHOOSE_TO_MIGRATE_TO_AZURE_SQL, + serverDatabases.length - migrationDatabases.size + ); + notStartedMigrationCard.onDidClick((e) => { + vscode.window.showInformationMessage('Feature coming soon'); + }); + this._migrationStatusCardsContainer.addItem( + notStartedMigrationCard + ); + } catch (error) { + console.log(error); + } finally { + this._migrationStatusCardLoadingContainer.loading = false; + } + + } + + private async getMigrations(): Promise { + const currentConnection = (await azdata.connection.getCurrentConnection()); + const localMigrations = MigrationLocalStorage.getMigrationsBySourceConnections(currentConnection); + for (let i = 0; i < localMigrations.length; i++) { + const localMigration = localMigrations[i]; + localMigration.migrationContext = await getDatabaseMigration( + localMigration.azureAccount, + localMigration.subscription, + localMigration.targetManagedInstance.location, + localMigration.migrationContext.id + ); + localMigration.sourceConnectionProfile = currentConnection; + } + return localMigrations; + } + + private createStatusCard( + cardIconPath: IconPath, + cardTitle: string, + cardDescription: string, + count: number + ): azdata.DivContainer { + + const cardTitleText = this._view.modelBuilder.text().withProps({ value: cardTitle }).withProps({ + CSSStyles: { + 'font-weight': 'bold', + 'height': '20px', + 'margin-top': '6px', + 'margin-bottom': '0px', + 'width': '300px', + 'font-size': '14px' + } + }).component(); + const cardDescriptionText = this._view.modelBuilder.text().withProps({ value: cardDescription }).withProps({ + CSSStyles: { + 'height': '18px', + 'margin-top': '0px', + 'margin-bottom': '0px', + 'width': '300px' + } + }).component(); + const cardCount = this._view.modelBuilder.text().withProps({ + value: count.toString(), + CSSStyles: { + 'font-size': '28px', + 'line-height': '36px', + 'margin-top': '4px' + } + }).component(); + + const flexContainer = this._view.modelBuilder.flexContainer().withItems([ + cardTitleText, + cardDescriptionText + ]).withLayout({ + flexFlow: 'column' + }).withProps({ + CSSStyles: { + 'width': '300px', + 'height': '50px' + } + }).component(); + + const flex = this._view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'width': '400px', + 'height': '50px', + 'margin-top': '10px', + 'box-shadow': '0 1px 2px 0 rgba(0,0,0,0.2)' + } + }).component(); + + const img = this._view.modelBuilder.image().withProps({ + iconPath: cardIconPath!.light, + iconHeight: 16, + iconWidth: 16, + width: 64, + height: 50 + }).component(); + + flex.addItem(img, { + flex: '0' }); + flex.addItem(flexContainer, { + flex: '0', + CSSStyles: { + 'width': '300px' + } + }); + flex.addItem(cardCount, { + flex: '0' + }); + + const compositeButton = this._view.modelBuilder.divContainer().withItems([flex]).withProps({ + ariaRole: 'button', + ariaLabel: 'show status', + clickable: true + }).component(); + return compositeButton; } private async createFooter(view: azdata.ModelView): Promise { @@ -258,14 +406,22 @@ export class DashboardWidget { const viewAllButton = view.modelBuilder.hyperlink().withProps({ label: loc.VIEW_ALL, - url: '' + url: '', + CSSStyles: { + 'font-size': '13px' + } }).component(); + viewAllButton.onDidClick(async (e) => { + new MigrationStatusDialog(await this.getMigrations(), MigrationCategory.ALL).initialize(); + }); + const refreshButton = view.modelBuilder.hyperlink().withProps({ label: loc.REFRESH, url: '', CSSStyles: { - 'text-align': 'right' + 'text-align': 'right', + 'font-size': '13px' } }).component(); @@ -305,6 +461,7 @@ export class DashboardWidget { this._migrationStatusCardsContainer = view.modelBuilder.flexContainer().withLayout({ flexFlow: 'column' }).component(); + this._migrationStatusCardLoadingContainer = view.modelBuilder.loadingComponent().withItem(this._migrationStatusCardsContainer).component(); statusContainer.addItem( header, { @@ -318,7 +475,7 @@ export class DashboardWidget { } ); - statusContainer.addItem(this._migrationStatusCardsContainer, { + statusContainer.addItem(this._migrationStatusCardLoadingContainer, { CSSStyles: { 'margin-top': '30px' } @@ -374,12 +531,12 @@ export class DashboardWidget { const videosContainer = this.createVideoLinkContainers(view, [ { - iconPath: IconPathHelper.sqlMiImportHelpThumbnail, + iconPath: IconPathHelper.sqlMiVideoThumbnail, description: loc.HELP_VIDEO1_TITLE, link: 'https://www.youtube.com/watch?v=sE99cSoFOHs' //TODO: Fix Video link }, { - iconPath: IconPathHelper.sqlVmImportHelpThumbnail, + iconPath: IconPathHelper.sqlVmVideoThumbnail, description: loc.HELP_VIDEO2_TITLE, link: 'https://www.youtube.com/watch?v=R4GCBoxADyQ' //TODO: Fix video link } @@ -447,13 +604,10 @@ export class DashboardWidget { flexFlow: 'row', width: maxWidth, }).component(); - links.forEach(link => { const videoContainer = this.createVideoLink(view, link); - videosContainer.addItem(videoContainer); }); - return videosContainer; } diff --git a/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts b/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts index eb08fc1f21..642e8b5f55 100644 --- a/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts +++ b/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts @@ -7,6 +7,7 @@ import * as azdata from 'azdata'; import { MigrationStateModel } from '../../models/stateMachine'; import { SqlDatabaseTree } from './sqlDatabasesTree'; import { SqlMigrationImpactedObjectInfo } from '../../../../mssql/src/mssql'; +import { SKURecommendationPage } from '../../wizard/skuRecommendationPage'; export type Issues = { description: string, @@ -30,7 +31,7 @@ export class AssessmentResultsDialog { private _tree: SqlDatabaseTree; - constructor(public ownerUri: string, public model: MigrationStateModel, public title: string) { + constructor(public ownerUri: string, public model: MigrationStateModel, public title: string, private skuRecommendationPage: SKURecommendationPage) { this._model = model; let assessmentData = this.parseData(this._model); this._tree = new SqlDatabaseTree(this._model, assessmentData); @@ -126,6 +127,7 @@ export class AssessmentResultsDialog { protected async execute() { this.model._migrationDbs = this._tree.selectedDbs(); + this.skuRecommendationPage.refreshDatabaseCount(this._model._migrationDbs.length); this._isOpen = false; } diff --git a/extensions/sql-migration/src/dialog/createMigrationDialog/createMigrationControllerDialog.ts b/extensions/sql-migration/src/dialog/createMigrationDialog/createMigrationControllerDialog.ts index d5a269cdff..a1d432e321 100644 --- a/extensions/sql-migration/src/dialog/createMigrationDialog/createMigrationControllerDialog.ts +++ b/extensions/sql-migration/src/dialog/createMigrationDialog/createMigrationControllerDialog.ts @@ -7,7 +7,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { createMigrationController, getMigrationControllerRegions, getMigrationController, getResourceGroups, getMigrationControllerAuthKeys, getMigrationControllerMonitoringData } from '../../api/azure'; import { MigrationStateModel } from '../../models/stateMachine'; -import * as constants from '../../models/strings'; +import * as constants from '../../constants/strings'; import * as os from 'os'; import { azureResource } from 'azureResource'; import { IntergrationRuntimePage } from '../../wizard/integrationRuntimePage'; @@ -130,7 +130,6 @@ export class CreateMigrationControllerDialog { this._dialogObject.okButton.enabled = false; azdata.window.openDialog(this._dialogObject); this._dialogObject.cancelButton.onClick((e) => { - this.migrationStateModel._migrationController = undefined!; }); this._dialogObject.okButton.onClick((e) => { this.irPage.populateMigrationController(); diff --git a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts new file mode 100644 index 0000000000..8346a3e093 --- /dev/null +++ b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts @@ -0,0 +1,440 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import { IconPathHelper } from '../../constants/iconPathHelper'; +import { MigrationContext } from '../../models/migrationLocalStorage'; +import { MigrationCutoverDialogModel } from './migrationCutoverDialogModel'; +import * as loc from '../../constants/strings'; +export class MigrationCutoverDialog { + private _dialogObject!: azdata.window.Dialog; + private _view!: azdata.ModelView; + private _model: MigrationCutoverDialogModel; + + private _databaseTitleName!: azdata.TextComponent; + private _databaseCutoverButton!: azdata.ButtonComponent; + private _refresh!: azdata.ButtonComponent; + + private _serverName!: azdata.TextComponent; + private _serverVersion!: azdata.TextComponent; + private _targetServer!: azdata.TextComponent; + private _targetVersion!: azdata.TextComponent; + private _migrationStatus!: azdata.TextComponent; + private _fullBackupFile!: azdata.TextComponent; + private _lastAppliedLSN!: azdata.TextComponent; + private _lastAppliedBackupFile!: azdata.TextComponent; + private _lastAppliedBackupTakenOn!: azdata.TextComponent; + + private _fileCount!: azdata.TextComponent; + + private fileTable!: azdata.TableComponent; + + private _startCutover!: boolean; + + constructor(migration: MigrationContext) { + this._model = new MigrationCutoverDialogModel(migration); + this._dialogObject = azdata.window.createModelViewDialog(loc.MIGRATION_CUTOVER, 'MigrationCutoverDialog', 1000); + } + + async initialize(): Promise { + let tab = azdata.window.createTab(''); + tab.registerContent(async (view: azdata.ModelView) => { + this._view = view; + const sourceDetails = this.createInfoField(loc.SOURCE_VERSION, ''); + const sourceVersion = this.createInfoField(loc.SOURCE_VERSION, ''); + + this._serverName = sourceDetails.text; + this._serverVersion = sourceVersion.text; + + const flexServer = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + + flexServer.addItem(sourceDetails.flexContainer, { + CSSStyles: { + 'width': '150px' + } + }); + flexServer.addItem(sourceVersion.flexContainer, { + CSSStyles: { + 'width': '150px' + } + }); + + const targetServer = this.createInfoField(loc.TARGET_SERVER, ''); + const targetVersion = this.createInfoField(loc.TARGET_VERSION, ''); + + this._targetServer = targetServer.text; + this._targetVersion = targetVersion.text; + + const flexTarget = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + + flexTarget.addItem(targetServer.flexContainer, { + CSSStyles: { + 'width': '230px' + } + }); + flexTarget.addItem(targetVersion.flexContainer, { + CSSStyles: { + 'width': '230px' + } + }); + + const migrationStatus = this.createInfoField(loc.MIGRATION_STATUS, ''); + const fullBackupFileOn = this.createInfoField(loc.FULL_BACKUP_FILES, ''); + + + this._migrationStatus = migrationStatus.text; + this._fullBackupFile = fullBackupFileOn.text; + + const flexStatus = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + + flexStatus.addItem(migrationStatus.flexContainer, { + CSSStyles: { + 'width': '180px' + } + }); + flexStatus.addItem(fullBackupFileOn.flexContainer, { + CSSStyles: { + 'width': '180px' + } + }); + + const lastSSN = this.createInfoField(loc.LAST_APPLIED_LSN, ''); + const lastAppliedBackup = this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, ''); + const lastAppliedBackupOn = this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES_TAKEN_ON, ''); + + this._lastAppliedLSN = lastSSN.text; + this._lastAppliedBackupFile = lastAppliedBackup.text; + this._lastAppliedBackupTakenOn = lastAppliedBackupOn.text; + + const flexFile = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + flexFile.addItem(lastSSN.flexContainer, { + CSSStyles: { + 'width': '230px' + } + }); + flexFile.addItem(lastAppliedBackup.flexContainer, { + CSSStyles: { + 'width': '230px' + } + }); + flexFile.addItem(lastAppliedBackupOn.flexContainer, { + CSSStyles: { + 'width': '230px' + } + }); + const flexInfo = view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'width': '700px' + } + }).component(); + + flexInfo.addItem(flexServer, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': '150px' + } + }); + + flexInfo.addItem(flexTarget, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': '230px' + } + }); + + flexInfo.addItem(flexStatus, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': '180px' + } + }); + + flexInfo.addItem(flexFile, { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': '200px' + } + }); + + this._fileCount = view.modelBuilder.text().withProps({ + width: '500px', + CSSStyles: { + 'font-size': '14px', + 'font-weight': 'bold' + } + }).component(); + + this.fileTable = view.modelBuilder.table().withProps({ + columns: [ + { + value: loc.ACTIVE_BACKUP_FILES, + width: 150, + type: azdata.ColumnType.text + }, + { + value: loc.TYPE, + width: 100, + type: azdata.ColumnType.text + }, + { + value: loc.STATUS, + width: 100, + type: azdata.ColumnType.text + }, + { + value: loc.BACKUP_START_TIME, + width: 150, + type: azdata.ColumnType.text + }, { + value: loc.FIRST_LSN, + width: 150, + type: azdata.ColumnType.text + }, { + value: loc.LAST_LSN, + width: 150, + type: azdata.ColumnType.text + } + ], + data: [], + width: '800px', + height: '600px', + }).component(); + + const formBuilder = view.modelBuilder.formContainer().withFormItems( + [ + { + component: await this.migrationContainerHeader() + }, + { + component: flexInfo + }, + { + component: this._fileCount + }, + { + component: this.fileTable + } + ], + { + horizontal: false + } + ); + const form = formBuilder.withLayout({ width: '100%' }).component(); + return view.initializeModel(form); + }); + this._dialogObject.content = [tab]; + azdata.window.openDialog(this._dialogObject); + this.refreshStatus(); + } + + + private migrationContainerHeader(): azdata.FlexContainer { + const header = this._view.modelBuilder.flexContainer().withLayout({ + }).component(); + + this._databaseTitleName = this._view.modelBuilder.text().withProps({ + CSSStyles: { + 'font-size': 'large', + 'width': '400px' + }, + value: this._model._migration.migrationContext.name + }).component(); + + header.addItem(this._databaseTitleName, { + flex: '0', + CSSStyles: { + 'width': '500px' + } + }); + + this._databaseCutoverButton = this._view.modelBuilder.button().withProps({ + iconPath: IconPathHelper.cutover, + iconHeight: '14px', + iconWidth: '12px', + label: 'Start Cutover', + height: '55px', + width: '100px', + enabled: false + }).component(); + + this._databaseCutoverButton.onDidClick(async (e) => { + if (this._startCutover) { + await this._model.startCutover(); + this.refreshStatus(); + } else { + this._dialogObject.message = { + text: loc.CANNOT_START_CUTOVER_ERROR, + level: azdata.window.MessageLevel.Error + }; + } + }); + + header.addItem(this._databaseCutoverButton, { + flex: '0', + CSSStyles: { + 'width': '100px' + } + }); + + this._refresh = this._view.modelBuilder.button().withProps({ + iconPath: IconPathHelper.refresh, + iconHeight: '16px', + iconWidth: '16px', + label: 'Refresh', + height: '55px', + width: '100px' + }).component(); + + this._refresh.onDidClick((e) => { + this.refreshStatus(); + }); + + header.addItem(this._refresh, { + flex: '0', + CSSStyles: { + 'width': '100px' + } + }); + + return header; + } + + + private async refreshStatus(): Promise { + try { + await this._model.fetchStatus(); + const sqlServerInfo = await azdata.connection.getServerInfo(this._model._migration.sourceConnectionProfile.connectionId); + const sqlServerName = this._model._migration.sourceConnectionProfile.serverName; + const sqlServerVersion = sqlServerInfo.serverVersion; + const sqlServerEdition = sqlServerInfo.serverEdition; + const targetServerName = this._model._migration.targetManagedInstance.name; + let targetServerVersion; + if (this._model.migrationStatus.id.includes('managedInstances')) { + targetServerVersion = loc.AZURE_SQL_DATABASE_MANAGED_INSTANCE; + } else { + targetServerVersion = loc.AZURE_SQL_DATABASE_VIRTUAL_MACHINE; + } + + const migrationStatusTextValue = this._model.migrationStatus.properties.migrationStatus; + + let fullBackupFileName: string; + let lastAppliedSSN: string; + let lastAppliedBackupFileTakenOn: string; + + + const tableData: ActiveBackupFileSchema[] = []; + + this._model.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.forEach((activeBackupSet) => { + tableData.push( + { + fileName: activeBackupSet.listOfBackupFiles[0].fileName, + type: activeBackupSet.backupType, + status: activeBackupSet.listOfBackupFiles[0].status, + backupStartTime: activeBackupSet.backupStartDate, + firstLSN: activeBackupSet.firstLSN, + lastLSN: activeBackupSet.lastLSN + } + ); + if (activeBackupSet.listOfBackupFiles[0].fileName.substr(activeBackupSet.listOfBackupFiles[0].fileName.lastIndexOf('.') + 1) === 'bak') { + fullBackupFileName = activeBackupSet.listOfBackupFiles[0].fileName; + } + if (activeBackupSet.listOfBackupFiles[0].fileName === this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename) { + lastAppliedSSN = activeBackupSet.lastLSN; + lastAppliedBackupFileTakenOn = activeBackupSet.backupFinishDate; + } + }); + + this._serverName.value = sqlServerName; + this._serverVersion.value = `${sqlServerVersion} + ${sqlServerEdition}`; + + this._targetServer.value = targetServerName; + this._targetVersion.value = targetServerVersion; + + this._migrationStatus.value = migrationStatusTextValue; + this._fullBackupFile.value = fullBackupFileName!; + + this._lastAppliedLSN.value = lastAppliedSSN!; + this._lastAppliedBackupFile.value = this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename; + this._lastAppliedBackupTakenOn.value = new Date(lastAppliedBackupFileTakenOn!).toLocaleString(); + + this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length); + + this.fileTable.data = tableData.map((row) => { + return [ + row.fileName, + row.type, + row.status, + new Date(row.backupStartTime).toLocaleString(), + row.firstLSN, + row.lastLSN + ]; + }); + if (this._model.migrationStatus.properties.migrationStatusDetails?.isFullBackupRestored) { + this._startCutover = true; + } + + if (migrationStatusTextValue === 'InProgress') { + this._databaseCutoverButton.enabled = true; + } else { + this._databaseCutoverButton.enabled = false; + } + } catch (e) { + console.log(e); + } + } + + private createInfoField(label: string, value: string): { + flexContainer: azdata.FlexContainer, + text: azdata.TextComponent + } { + const flexContainer = this._view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column' + }).component(); + + const labelComponent = this._view.modelBuilder.text().withProps({ + value: label, + CSSStyles: { + 'font-weight': 'bold', + 'margin-bottom': '0' + } + }).component(); + flexContainer.addItem(labelComponent); + + const textComponent = this._view.modelBuilder.text().withProps({ + value: value, + CSSStyles: { + 'margin-top': '5px', + 'margin-bottom': '0' + } + }).component(); + flexContainer.addItem(textComponent); + return { + flexContainer: flexContainer, + text: textComponent + }; + } +} + +interface ActiveBackupFileSchema { + fileName: string, + type: string, + status: string, + backupStartTime: string, + firstLSN: string, + lastLSN: string +} diff --git a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts new file mode 100644 index 0000000000..66d5a82dee --- /dev/null +++ b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getMigrationStatus, DatabaseMigration, startMigrationCutover } from '../../api/azure'; +import { MigrationContext } from '../../models/migrationLocalStorage'; + + +export class MigrationCutoverDialogModel { + + public migrationStatus!: DatabaseMigration; + + constructor(public _migration: MigrationContext) { + } + + public async fetchStatus(): Promise { + this.migrationStatus = (await getMigrationStatus( + this._migration.azureAccount, + this._migration.subscription, + this._migration.migrationContext + )); + } + + public async startCutover(): Promise { + try { + if (this.migrationStatus) { + return await startMigrationCutover( + this._migration.azureAccount, + this._migration.subscription, + this.migrationStatus + ); + } + } catch (error) { + console.log(error); + } + return undefined!; + } +} diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts new file mode 100644 index 0000000000..34d014bdb9 --- /dev/null +++ b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { IconPathHelper } from '../../constants/iconPathHelper'; +import { MigrationContext } from '../../models/migrationLocalStorage'; +import { MigrationCutoverDialog } from '../migrationCutover/migrationCutoverDialog'; +import { MigrationCategory, MigrationStatusDialogModel } from './migrationStatusDialogModel'; +import * as loc from '../../constants/strings'; +export class MigrationStatusDialog { + private _model: MigrationStatusDialogModel; + private _dialogObject!: azdata.window.Dialog; + private _view!: azdata.ModelView; + private _searchBox!: azdata.InputBoxComponent; + private _refresh!: azdata.ButtonComponent; + private _statusDropdown!: azdata.DropDownComponent; + private _statusTable!: azdata.DeclarativeTableComponent; + + constructor(migrations: MigrationContext[], private _filter: MigrationCategory) { + this._model = new MigrationStatusDialogModel(migrations); + this._dialogObject = azdata.window.createModelViewDialog(loc.MIGRATION_STATUS, 'MigrationControllerDialog', 'wide'); + } + + initialize() { + let tab = azdata.window.createTab(''); + tab.registerContent((view: azdata.ModelView) => { + this._view = view; + + this._statusDropdown = this._view.modelBuilder.dropDown().withProps({ + values: this._model.statusDropdownValues, + width: '220px' + }).component(); + + this._statusDropdown.onValueChanged((value) => { + this.populateMigrationTable(); + }); + + this._statusDropdown.value = this._statusDropdown.values![this._filter]; + + const formBuilder = view.modelBuilder.formContainer().withFormItems( + [ + { + component: this.createSearchAndRefreshContainer() + }, + { + component: this._statusDropdown + }, + { + component: this.createStatusTable() + } + ], + { + horizontal: false + } + ); + const form = formBuilder.withLayout({ width: '100%' }).component(); + return view.initializeModel(form); + }); + this._dialogObject.content = [tab]; + azdata.window.openDialog(this._dialogObject); + } + + private createSearchAndRefreshContainer(): azdata.FlexContainer { + this._searchBox = this._view.modelBuilder.inputBox().withProps({ + placeHolder: loc.SEARCH_FOR_MIGRATIONS, + width: '360px' + }).component(); + + this._searchBox.onTextChanged((value) => { + this.populateMigrationTable(); + }); + + this._refresh = this._view.modelBuilder.button().withProps({ + iconPath: { + light: IconPathHelper.refresh.light, + dark: IconPathHelper.refresh.dark + }, + iconHeight: '16px', + iconWidth: '16px', + height: '30px', + label: 'Refresh', + }).component(); + + const flexContainer = this._view.modelBuilder.flexContainer().component(); + + flexContainer.addItem(this._searchBox, { + flex: '0' + }); + + flexContainer.addItem(this._refresh, { + flex: '0', + CSSStyles: { + 'margin-left': '20px' + } + }); + + return flexContainer; + } + + private populateMigrationTable(): void { + + try { + const migrations = this._model.filterMigration( + this._searchBox.value!, + (this._statusDropdown.value).name + ); + + const data: azdata.DeclarativeTableCellValue[][] = []; + + migrations.forEach((migration) => { + const migrationRow: azdata.DeclarativeTableCellValue[] = []; + + const databaseHyperLink = this._view.modelBuilder.hyperlink().withProps({ + label: migration.migrationContext.name, + url: '' + }).component(); + databaseHyperLink.onDidClick(async (e) => { + await (new MigrationCutoverDialog(migration)).initialize(); + }); + migrationRow.push({ + value: databaseHyperLink, + }); + + migrationRow.push({ + value: migration.migrationContext.properties.migrationStatus + }); + + const sqlMigrationIcon = this._view.modelBuilder.image().withProps({ + iconPath: IconPathHelper.sqlMigrationLogo, + iconWidth: '16px', + iconHeight: '16px', + width: '32px', + height: '20px' + }).component(); + const sqlMigrationName = this._view.modelBuilder.hyperlink().withProps({ + label: migration.migrationContext.name, + url: '' + }).component(); + sqlMigrationName.onDidClick((e) => { + vscode.window.showInformationMessage('Feature coming soon'); + }); + + const sqlMigrationContainer = this._view.modelBuilder.flexContainer().withProps({ + CSSStyles: { + 'justify-content': 'center' + } + }).component(); + sqlMigrationContainer.addItem(sqlMigrationIcon, { + flex: '0', + CSSStyles: { + 'width': '32px' + } + }); + sqlMigrationContainer.addItem(sqlMigrationName, + { + CSSStyles: { + 'width': 'auto' + } + }); + migrationRow.push({ + value: sqlMigrationContainer + }); + + migrationRow.push({ + value: loc.ONLINE + }); + + migrationRow.push({ + value: '---' + }); + migrationRow.push({ + value: '---' + }); + + data.push(migrationRow); + }); + + this._statusTable.dataValues = data; + } catch (e) { + console.log(e); + } + } + + private createStatusTable(): azdata.DeclarativeTableComponent { + this._statusTable = this._view.modelBuilder.declarativeTable().withProps({ + columns: [ + { + displayName: loc.DATABASE, + valueType: azdata.DeclarativeDataType.component, + width: '100px', + isReadOnly: true, + rowCssStyles: { + 'text-align': 'center' + } + }, + { + displayName: loc.MIGRATION_STATUS, + valueType: azdata.DeclarativeDataType.string, + width: '150px', + isReadOnly: true, + rowCssStyles: { + 'text-align': 'center' + } + }, + { + displayName: loc.TARGET_AZURE_SQL_INSTANCE_NAME, + valueType: azdata.DeclarativeDataType.component, + width: '300px', + isReadOnly: true, + rowCssStyles: { + 'text-align': 'center' + } + }, + { + displayName: loc.CUTOVER_TYPE, + valueType: azdata.DeclarativeDataType.string, + width: '100px', + isReadOnly: true, + rowCssStyles: { + 'text-align': 'center' + } + }, + { + displayName: loc.START_TIME, + valueType: azdata.DeclarativeDataType.string, + width: '150px', + isReadOnly: true, + rowCssStyles: { + 'text-align': 'center' + } + }, + { + displayName: loc.FINISH_TIME, + valueType: azdata.DeclarativeDataType.string, + width: '150px', + isReadOnly: true, + rowCssStyles: { + 'text-align': 'center' + } + } + ] + }).component(); + return this._statusTable; + } +} diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialogModel.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialogModel.ts new file mode 100644 index 0000000000..a058ee514d --- /dev/null +++ b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialogModel.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import { MigrationContext } from '../../models/migrationLocalStorage'; + +export class MigrationStatusDialogModel { + + public statusDropdownValues: azdata.CategoryValue[] = [ + { + displayName: 'Status: All', + name: 'All', + }, { + displayName: 'Status: Ongoing', + name: 'Ongoing', + }, { + displayName: 'Status: Succeeded', + name: 'Succeeded', + } + ]; + + constructor(public _migrations: MigrationContext[]) { + } + + public filterMigration(databaseName: string, category: string): MigrationContext[] { + let filteredMigration: MigrationContext[] = []; + if (category === 'All') { + filteredMigration = this._migrations; + } else if (category === 'Ongoing') { + filteredMigration = this._migrations.filter((value) => { + const status = value.migrationContext.properties.migrationStatus; + return status === 'InProgress' || status === 'Creating' || status === 'Completing'; + }); + } else if (category === 'Succeeded') { + filteredMigration = this._migrations.filter((value) => { + const status = value.migrationContext.properties.migrationStatus; + return status === 'Succeeded'; + }); + } + if (databaseName) { + filteredMigration = filteredMigration.filter((value) => { + return value.migrationContext.name.toLowerCase().includes(databaseName.toLowerCase()); + }); + } + + return filteredMigration; + } +} + +export enum MigrationCategory { + ALL, + ONGOING, + SUCCEEDED +} diff --git a/extensions/sql-migration/src/main.ts b/extensions/sql-migration/src/main.ts index be6a5e4a9e..7ba7651950 100644 --- a/extensions/sql-migration/src/main.ts +++ b/extensions/sql-migration/src/main.ts @@ -6,9 +6,8 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import { WizardController } from './wizard/wizardController'; -import { AssessmentResultsDialog } from './dialog/assessmentResults/assessmentResultsDialog'; import { promises as fs } from 'fs'; -import * as loc from './models/strings'; +import * as loc from './constants/strings'; import { MigrationNotebookInfo, NotebookPathHelper } from './constants/notebookPathHelper'; import { IconPathHelper } from './constants/iconPathHelper'; import { DashboardWidget } from './dashboard/sqlServerDashboard'; @@ -42,12 +41,6 @@ class SQLMigration { const wizardController = new WizardController(this.context); await wizardController.openWizard(connectionId); }), - - vscode.commands.registerCommand('sqlmigration.testDialog', async () => { - let dialog = new AssessmentResultsDialog('ownerUri', undefined!, 'Assessment Dialog'); - await dialog.openDialog(); - }), - vscode.commands.registerCommand('sqlmigration.openNotebooks', async () => { const input = vscode.window.createQuickPick(); input.placeholder = loc.NOTEBOOK_QUICK_PICK_PLACEHOLDER; diff --git a/extensions/sql-migration/src/models/migrationLocalStorage.ts b/extensions/sql-migration/src/models/migrationLocalStorage.ts index ce85361b32..5388bc4b2f 100644 --- a/extensions/sql-migration/src/models/migrationLocalStorage.ts +++ b/extensions/sql-migration/src/models/migrationLocalStorage.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; import { azureResource } from 'azureResource'; -import { DatabaseMigration, MigrationController, SqlManagedInstance } from '../api/azure'; +import { DatabaseMigration, SqlMigrationController, SqlManagedInstance } from '../api/azure'; import * as azdata from 'azdata'; @@ -16,7 +16,7 @@ export class MigrationLocalStorage { MigrationLocalStorage.context = context; } - public static getMigrations(connectionProfile: azdata.connection.ConnectionProfile): MigrationContext[] { + public static getMigrationsBySourceConnections(connectionProfile: azdata.connection.ConnectionProfile): MigrationContext[] { let dataBaseMigrations: MigrationContext[] = []; try { @@ -41,7 +41,7 @@ export class MigrationLocalStorage { targetMI: SqlManagedInstance, azureAccount: azdata.Account, subscription: azureResource.AzureResourceSubscription, - controller: MigrationController): void { + controller: SqlMigrationController): void { try { const migrationMementos: MigrationContext[] = this.context.globalState.get(this.mementoToken) || []; migrationMementos.push({ @@ -69,5 +69,5 @@ export interface MigrationContext { targetManagedInstance: SqlManagedInstance, azureAccount: azdata.Account, subscription: azureResource.AzureResourceSubscription, - controller: MigrationController + controller: SqlMigrationController } diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 94423c46c2..e27abc279e 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -7,9 +7,9 @@ import * as azdata from 'azdata'; import { azureResource } from 'azureResource'; import * as vscode from 'vscode'; import * as mssql from '../../../mssql'; -import { getAvailableManagedInstanceProducts, getAvailableStorageAccounts, getBlobContainers, getFileShares, getMigrationControllers, getSubscriptions, MigrationController, SqlManagedInstance, startDatabaseMigration, StartDatabaseMigrationRequest, StorageAccount } from '../api/azure'; +import { getAvailableManagedInstanceProducts, getAvailableStorageAccounts, getBlobContainers, getFileShares, getMigrationControllers, getSubscriptions, SqlMigrationController, SqlManagedInstance, startDatabaseMigration, StartDatabaseMigrationRequest, StorageAccount } from '../api/azure'; import { SKURecommendations } from './externalContract'; -import * as constants from '../models/strings'; +import * as constants from '../constants/strings'; import { MigrationLocalStorage } from './migrationLocalStorage'; export enum State { @@ -85,13 +85,13 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _targetManagedInstance!: SqlManagedInstance; public _databaseBackup!: DatabaseBackupModel; - public _migrationDbs!: string[]; + public _migrationDbs: string[] = []; public _storageAccounts!: StorageAccount[]; public _fileShares!: azureResource.FileShare[]; public _blobContainers!: azureResource.BlobContainer[]; - public _migrationController!: MigrationController; - public _migrationControllers!: MigrationController[]; + public _migrationController!: SqlMigrationController; + public _migrationControllers!: SqlMigrationController[]; public _nodeNames!: string[]; private _stateChangeEventEmitter = new vscode.EventEmitter(); @@ -403,7 +403,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { return migrationControllerValues; } - public getMigrationController(index: number): MigrationController { + public getMigrationController(index: number): SqlMigrationController { return this._migrationControllers[index]; } @@ -421,7 +421,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { const requestBody: StartDatabaseMigrationRequest = { location: this._migrationController?.properties.location!, properties: { - SourceDatabaseName: currentConnection?.databaseName!, + SourceDatabaseName: '', MigrationController: this._migrationController?.id!, BackupConfiguration: { TargetLocation: { @@ -445,26 +445,36 @@ export class MigrationStateModel implements Model, vscode.Disposable { } }; - const response = await startDatabaseMigration( - this._azureAccount, - this._targetSubscription, - this._targetManagedInstance.resourceGroup!, - this._migrationController?.properties.location!, - this._targetManagedInstance.name, - this._migrationController?.name!, - requestBody - ); + this._migrationDbs.forEach(async (db) => { - if (response.status === 201) { - MigrationLocalStorage.saveMigration( - currentConnection!, - response.databaseMigration, - this._targetManagedInstance, - this._azureAccount, - this._targetSubscription, - this._migrationController - ); - } + requestBody.properties.SourceDatabaseName = db; + try { + const response = await startDatabaseMigration( + this._azureAccount, + this._targetSubscription, + this._targetManagedInstance.resourceGroup!, + this._migrationController?.properties.location!, + this._targetManagedInstance.name, + currentConnection?.databaseName!, + requestBody + ); + + if (response.status === 201) { + MigrationLocalStorage.saveMigration( + currentConnection!, + response.databaseMigration, + this._targetManagedInstance, + this._azureAccount, + this._targetSubscription, + this._migrationController + ); + vscode.window.showInformationMessage(`Starting migration for database ${db} to ${this._targetManagedInstance.name}`); + } + } catch (e) { + vscode.window.showInformationMessage(e); + } + + }); vscode.window.showInformationMessage(constants.MIGRATION_STARTED); } diff --git a/extensions/sql-migration/src/wizard/accountsSelectionPage.ts b/extensions/sql-migration/src/wizard/accountsSelectionPage.ts index 9037bd9573..015907f4d4 100644 --- a/extensions/sql-migration/src/wizard/accountsSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/accountsSelectionPage.ts @@ -7,7 +7,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; -import * as constants from '../models/strings'; +import * as constants from '../constants/strings'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; export class AccountsSelectionPage extends MigrationWizardPage { diff --git a/extensions/sql-migration/src/wizard/databaseBackupPage.ts b/extensions/sql-migration/src/wizard/databaseBackupPage.ts index 7903e9730f..46f8cdac32 100644 --- a/extensions/sql-migration/src/wizard/databaseBackupPage.ts +++ b/extensions/sql-migration/src/wizard/databaseBackupPage.ts @@ -8,8 +8,8 @@ import { EOL } from 'os'; import { getStorageAccountAccessKeys } from '../api/azure'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationCutover, MigrationStateModel, NetworkContainerType, StateChangeEvent } from '../models/stateMachine'; -import * as constants from '../models/strings'; - +import * as constants from '../constants/strings'; +import * as vscode from 'vscode'; export class DatabaseBackupPage extends MigrationWizardPage { private _networkShareContainer!: azdata.FlexContainer; @@ -85,7 +85,9 @@ export class DatabaseBackupPage extends MigrationWizardPage { blobContainerButton.onDidChangeCheckedState((e) => { if (e) { - this.toggleNetworkContainerFields(NetworkContainerType.BLOB_CONTAINER); + vscode.window.showInformationMessage('Feature coming soon'); + networkShareButton.checked = true; + //this.toggleNetworkContainerFields(NetworkContainerType.BLOB_CONTAINER); } }); @@ -97,7 +99,9 @@ export class DatabaseBackupPage extends MigrationWizardPage { fileShareButton.onDidChangeCheckedState((e) => { if (e) { - this.toggleNetworkContainerFields(NetworkContainerType.FILE_SHARE); + vscode.window.showInformationMessage('Feature coming soon'); + networkShareButton.checked = true; + //this.toggleNetworkContainerFields(NetworkContainerType.FILE_SHARE); } }); @@ -414,7 +418,9 @@ export class DatabaseBackupPage extends MigrationWizardPage { offlineButton.onDidChangeCheckedState((e) => { if (e) { - this.migrationStateModel._databaseBackup.migrationCutover = MigrationCutover.OFFLINE; + vscode.window.showInformationMessage('Feature coming soon'); + onlineButton.checked = true; + //this.migrationStateModel._databaseBackup.migrationCutover = MigrationCutover.OFFLINE; } }); @@ -489,7 +495,9 @@ export class DatabaseBackupPage extends MigrationWizardPage { public async onPageLeave(): Promise { this.migrationStateModel._databaseBackup.storageKey = (await getStorageAccountAccessKeys(this.migrationStateModel._azureAccount, this.migrationStateModel._databaseBackup.subscription, this.migrationStateModel._databaseBackup.storageAccount)).keyName1; - console.log(this.migrationStateModel._databaseBackup); + this.wizard.registerNavigationValidator((pageChangeInfo) => { + return true; + }); } protected async handleStateChange(e: StateChangeEvent): Promise { diff --git a/extensions/sql-migration/src/wizard/integrationRuntimePage.ts b/extensions/sql-migration/src/wizard/integrationRuntimePage.ts index 2e9285b230..96fc8b49e7 100644 --- a/extensions/sql-migration/src/wizard/integrationRuntimePage.ts +++ b/extensions/sql-migration/src/wizard/integrationRuntimePage.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; import { CreateMigrationControllerDialog } from '../dialog/createMigrationDialog/createMigrationControllerDialog'; -import * as constants from '../models/strings'; +import * as constants from '../constants/strings'; import { createInformationRow, WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; import { getMigrationController, getMigrationControllerAuthKeys, getMigrationControllerMonitoringData } from '../api/azure'; import { IconPathHelper } from '../constants/iconPathHelper'; @@ -147,7 +147,7 @@ export class IntergrationRuntimePage extends MigrationWizardPage { return flexContainer; } - public async populateMigrationController(controllerStatus?: string): Promise { + public async populateMigrationController(): Promise { this.migrationControllerDropdown.loading = true; try { this.migrationControllerDropdown.values = await this.migrationStateModel.getMigrationControllerValues(this.migrationStateModel._targetSubscription, this.migrationStateModel._targetManagedInstance); diff --git a/extensions/sql-migration/src/wizard/skuRecommendationPage.ts b/extensions/sql-migration/src/wizard/skuRecommendationPage.ts index e78aea889e..ebe84dada8 100644 --- a/extensions/sql-migration/src/wizard/skuRecommendationPage.ts +++ b/extensions/sql-migration/src/wizard/skuRecommendationPage.ts @@ -8,11 +8,10 @@ import * as path from 'path'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; import { Product, ProductLookupTable } from '../models/product'; -import { Disposable } from 'vscode'; import { AssessmentResultsDialog } from '../dialog/assessmentResults/assessmentResultsDialog'; -import { getAvailableManagedInstanceProducts, getSubscriptions, SqlManagedInstance, Subscription } from '../api/azure'; -import * as constants from '../models/strings'; -import { azureResource } from 'azureResource'; +import * as constants from '../constants/strings'; +import * as vscode from 'vscode'; +import { EOL } from 'os'; // import { SqlMigrationService } from '../../../../extensions/mssql/src/sqlMigration/sqlMigrationService'; @@ -32,11 +31,11 @@ export class SKURecommendationPage extends MigrationWizardPage { private _azureSubscriptionText: azdata.FormComponent | undefined; private _managedInstanceSubscriptionDropdown!: azdata.DropDownComponent; private _managedInstanceDropdown!: azdata.DropDownComponent; - private _subscriptionDropdownValues: azdata.CategoryValue[] = []; - private _subscriptionMap: Map = new Map(); private _view: azdata.ModelView | undefined; + private _rbg!: azdata.RadioCardGroupComponent; private async initialState(view: azdata.ModelView) { + this._view = view; this._igComponent = this.createStatusComponent(view); // The first component giving basic information this._detailsComponent = this.createDetailsComponent(view); // The details of what can be moved this._chooseTargetComponent = this.createChooseTargetComponent(view); @@ -47,12 +46,24 @@ export class SKURecommendationPage extends MigrationWizardPage { }).component(); this._managedInstanceSubscriptionDropdown = view.modelBuilder.dropDown().component(); this._managedInstanceSubscriptionDropdown.onValueChanged((e) => { - this.populateManagedInstanceDropdown(); + if (e.selected) { + this.migrationStateModel._targetSubscription = this.migrationStateModel.getSubscription(e.index); + this.migrationStateModel._targetManagedInstance = undefined!; + this.migrationStateModel._migrationController = undefined!; + this.populateManagedInstanceDropdown(); + } }); const managedInstanceDropdownLabel = view.modelBuilder.text().withProps({ value: constants.MANAGED_INSTANCE }).component(); + this._managedInstanceDropdown = view.modelBuilder.dropDown().component(); + this._managedInstanceDropdown.onValueChanged((e) => { + if (e.selected) { + this.migrationStateModel._migrationControllers = undefined!; + this.migrationStateModel._targetManagedInstance = this.migrationStateModel.getManagedInstance(e.index); + } + }); const targetContainer = view.modelBuilder.flexContainer().withItems( [ @@ -137,18 +148,23 @@ export class SKURecommendationPage extends MigrationWizardPage { private constructTargets(): void { const products: Product[] = Object.values(ProductLookupTable); - const rbg = this._view!.modelBuilder.radioCardGroup().withProperties({ + this._rbg = this._view!.modelBuilder.radioCardGroup().withProperties({ cards: [], cardWidth: '600px', cardHeight: '60px', orientation: azdata.Orientation.Vertical, iconHeight: '30px', iconWidth: '30px' - }); + }).component(); products.forEach((product) => { const imagePath = path.resolve(this.migrationStateModel.getExtensionPath(), 'media', product.icon ?? 'ads.svg'); - + let dbCount = 0; + if (product.type === 'AzureSQLVM') { + dbCount = 0; + } else { + dbCount = this.migrationStateModel._migrationDbs.length; + } const descriptions: azdata.RadioCardDescription[] = [ { textValue: product.name, @@ -164,7 +180,7 @@ export class SKURecommendationPage extends MigrationWizardPage { } }, { - textValue: '9 databases will be migrated', + textValue: `${dbCount} databases will be migrated`, linkDisplayValue: 'View/Change', displayLinkCodicon: true, linkCodiconStyles: { @@ -174,22 +190,31 @@ export class SKURecommendationPage extends MigrationWizardPage { } ]; - rbg.component().cards.push({ - id: product.name, + this._rbg.cards.push({ + id: product.type, icon: imagePath, descriptions }); }); - rbg.component().onLinkClick(async (value) => { + this._rbg.onLinkClick(async (value) => { //check which card is being selected, and open correct dialog based on link console.log(value); - let dialog = new AssessmentResultsDialog('ownerUri', this.migrationStateModel, 'Assessment Dialog'); + let dialog = new AssessmentResultsDialog('ownerUri', this.migrationStateModel, 'Assessment Dialog', this); await dialog.openDialog(); }); - this._chooseTargetComponent?.component.addItem(rbg.component()); + this._rbg.onSelectionChanged((value) => { + if (value.cardId === 'AzureSQLVM') { + vscode.window.showInformationMessage('Feature coming soon'); + this._rbg.selectedCardId = 'AzureSQLMI'; + } + }); + + this._rbg.selectedCardId = 'AzureSQLMI'; + + this._chooseTargetComponent?.component.addItem(this._rbg); } private createAzureSubscriptionText(view: azdata.ModelView): azdata.FormComponent { @@ -205,85 +230,78 @@ export class SKURecommendationPage extends MigrationWizardPage { } private async populateSubscriptionDropdown(): Promise { - this._managedInstanceSubscriptionDropdown.loading = true; - this._managedInstanceDropdown.loading = true; - let subscriptions: azureResource.AzureResourceSubscription[] = []; - try { - subscriptions = await getSubscriptions(this.migrationStateModel._azureAccount); - subscriptions.forEach((subscription) => { - this._subscriptionMap.set(subscription.id, subscription); - this._subscriptionDropdownValues.push({ - name: subscription.id, - displayName: subscription.name + ' - ' + subscription.id, - }); - }); - - if (!this._subscriptionDropdownValues || this._subscriptionDropdownValues.length === 0) { - this._subscriptionDropdownValues = [ - { - displayName: constants.NO_SUBSCRIPTIONS_FOUND, - name: '' - } - ]; + if (!this.migrationStateModel._targetSubscription) { + this._managedInstanceSubscriptionDropdown.loading = true; + this._managedInstanceDropdown.loading = true; + try { + this._managedInstanceSubscriptionDropdown.values = await this.migrationStateModel.getSubscriptionsDropdownValues(); + } catch (e) { + console.log(e); + } finally { + this._managedInstanceSubscriptionDropdown.loading = false; } - - this._managedInstanceSubscriptionDropdown.values = this._subscriptionDropdownValues; - } catch (error) { - this.setEmptyDropdownPlaceHolder(this._managedInstanceSubscriptionDropdown, constants.NO_SUBSCRIPTIONS_FOUND); - this._managedInstanceDropdown.loading = false; } - this.populateManagedInstanceDropdown(); - this._managedInstanceSubscriptionDropdown.loading = false; } private async populateManagedInstanceDropdown(): Promise { - this._managedInstanceDropdown.loading = true; - let mis: SqlManagedInstance[] = []; - let miValues: azdata.CategoryValue[] = []; - try { - const subscriptionId = (this._managedInstanceSubscriptionDropdown.value).name; - - mis = await getAvailableManagedInstanceProducts(this.migrationStateModel._azureAccount, this._subscriptionMap.get(subscriptionId)!); - mis.forEach((mi) => { - miValues.push({ - name: mi.name, - displayName: mi.name - }); - }); - - if (!miValues || miValues.length === 0) { - miValues = [ - { - displayName: constants.NO_MANAGED_INSTANCE_FOUND, - name: '' - } - ]; + if (!this.migrationStateModel._targetManagedInstance) { + this._managedInstanceDropdown.loading = true; + try { + this._managedInstanceDropdown.values = await this.migrationStateModel.getManagedInstanceValues(this.migrationStateModel._targetSubscription); + } catch (e) { + console.log(e); + } finally { + this._managedInstanceDropdown.loading = false; } - - this._managedInstanceDropdown.values = miValues; - } catch (error) { - this.setEmptyDropdownPlaceHolder(this._managedInstanceDropdown, constants.NO_MANAGED_INSTANCE_FOUND); } - - this._managedInstanceDropdown.loading = false; } - private setEmptyDropdownPlaceHolder(dropDown: azdata.DropDownComponent, placeholder: string): void { - dropDown.values = [{ - displayName: placeholder, - name: '' - }]; - } - - private eventListener: Disposable | undefined; + private eventListener: vscode.Disposable | undefined; public async onPageEnter(): Promise { this.eventListener = this.migrationStateModel.stateChangeEvent(async (e) => this.onStateChangeEvent(e)); this.populateSubscriptionDropdown(); this.constructDetails(); + + this.wizard.registerNavigationValidator((pageChangeInfo) => { + const errors: string[] = []; + this.wizard.message = { + text: '', + level: azdata.window.MessageLevel.Error + }; + if (pageChangeInfo.newPage < pageChangeInfo.lastPage) { + return true; + } + if (this.migrationStateModel._migrationDbs.length === 0) { + errors.push('Please select databases to migrate'); + + } + if ((this._managedInstanceSubscriptionDropdown.value).displayName === constants.NO_SUBSCRIPTIONS_FOUND) { + errors.push(constants.INVALID_SUBSCRIPTION_ERROR); + } + if ((this._managedInstanceDropdown.value).displayName === constants.NO_MANAGED_INSTANCE_FOUND) { + errors.push(constants.INVALID_STORAGE_ACCOUNT_ERROR); + } + + if (errors.length > 0) { + this.wizard.message = { + text: errors.join(EOL), + level: azdata.window.MessageLevel.Error + }; + return false; + } + return true; + }); } public async onPageLeave(): Promise { this.eventListener?.dispose(); + this.wizard.message = { + text: '', + level: azdata.window.MessageLevel.Error + }; + this.wizard.registerNavigationValidator((pageChangeInfo) => { + return true; + }); } protected async handleStateChange(e: StateChangeEvent): Promise { @@ -292,4 +310,16 @@ export class SKURecommendationPage extends MigrationWizardPage { } } + public refreshDatabaseCount(count: number): void { + this.wizard.message = { + text: '', + level: azdata.window.MessageLevel.Error + }; + const textValue: string = `${count} databases will be migrated`; + this._rbg.cards[0].descriptions[1].textValue = textValue; + this._rbg.updateProperties({ + cards: this._rbg.cards + }); + } + } diff --git a/extensions/sql-migration/src/wizard/sourceConfigurationPage.ts b/extensions/sql-migration/src/wizard/sourceConfigurationPage.ts index 75e6a11a26..4d323053f0 100644 --- a/extensions/sql-migration/src/wizard/sourceConfigurationPage.ts +++ b/extensions/sql-migration/src/wizard/sourceConfigurationPage.ts @@ -5,7 +5,7 @@ import * as azdata from 'azdata'; import { MigrationWizardPage } from '../models/migrationWizardPage'; -import { SOURCE_CONFIGURATION_PAGE_TITLE, COLLECTING_SOURCE_CONFIGURATIONS, COLLECTING_SOURCE_CONFIGURATIONS_INFO, COLLECTING_SOURCE_CONFIGURATIONS_ERROR } from '../models/strings'; +import { SOURCE_CONFIGURATION_PAGE_TITLE, COLLECTING_SOURCE_CONFIGURATIONS, COLLECTING_SOURCE_CONFIGURATIONS_INFO, COLLECTING_SOURCE_CONFIGURATIONS_ERROR } from '../constants/strings'; import { MigrationStateModel, StateChangeEvent, State } from '../models/stateMachine'; import { Disposable } from 'vscode'; diff --git a/extensions/sql-migration/src/wizard/subscriptionSelectionPage.ts b/extensions/sql-migration/src/wizard/subscriptionSelectionPage.ts index fae6011764..d5621398f0 100644 --- a/extensions/sql-migration/src/wizard/subscriptionSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/subscriptionSelectionPage.ts @@ -6,7 +6,7 @@ import * as azdata from 'azdata'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; -import { SUBSCRIPTION_SELECTION_PAGE_TITLE, SUBSCRIPTION_SELECTION_AZURE_ACCOUNT_TITLE, SUBSCRIPTION_SELECTION_AZURE_PRODUCT_TITLE, SUBSCRIPTION_SELECTION_AZURE_SUBSCRIPTION_TITLE } from '../models/strings'; +import { SUBSCRIPTION_SELECTION_PAGE_TITLE, SUBSCRIPTION_SELECTION_AZURE_ACCOUNT_TITLE, SUBSCRIPTION_SELECTION_AZURE_PRODUCT_TITLE, SUBSCRIPTION_SELECTION_AZURE_SUBSCRIPTION_TITLE } from '../constants/strings'; import { Disposable } from 'vscode'; import { getSubscriptions, Subscription, getAvailableManagedInstanceProducts, AzureProduct, getAvailableSqlServers } from '../api/azure'; diff --git a/extensions/sql-migration/src/wizard/summaryPage.ts b/extensions/sql-migration/src/wizard/summaryPage.ts index 0dd97980d6..816734cc02 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 { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationStateModel, NetworkContainerType, StateChangeEvent } from '../models/stateMachine'; -import * as constants from '../models/strings'; +import * as constants from '../constants/strings'; import { createHeadingTextComponent, createInformationRow } from './wizardController'; export class SummaryPage extends MigrationWizardPage { @@ -42,7 +42,7 @@ export class SummaryPage extends MigrationWizardPage { createInformationRow(this._view, constants.TYPE, constants.SUMMARY_MI_TYPE), createInformationRow(this._view, constants.SUBSCRIPTION, this.migrationStateModel._targetSubscription.name), createInformationRow(this._view, constants.SUMMARY_MI_TYPE, this.migrationStateModel._targetManagedInstance.name), - createInformationRow(this._view, constants.SUMMARY_DATABASE_COUNT_LABEL, '1'), + createInformationRow(this._view, constants.SUMMARY_DATABASE_COUNT_LABEL, this.migrationStateModel._migrationDbs.length.toString()), createHeadingTextComponent(this._view, constants.DATABASE_BACKUP_PAGE_TITLE), this.createNetworkContainerRows(), createHeadingTextComponent(this._view, constants.IR_PAGE_TITLE), diff --git a/extensions/sql-migration/src/wizard/tempTargetSelectionPage.ts b/extensions/sql-migration/src/wizard/tempTargetSelectionPage.ts index 9cc9384855..7a5cd8ab8a 100644 --- a/extensions/sql-migration/src/wizard/tempTargetSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/tempTargetSelectionPage.ts @@ -6,7 +6,7 @@ import * as azdata from 'azdata'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { MigrationStateModel, StateChangeEvent } from '../models/stateMachine'; -import * as constants from '../models/strings'; +import * as constants from '../constants/strings'; import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; export class TempTargetSelectionPage extends MigrationWizardPage { diff --git a/extensions/sql-migration/src/wizard/wizardController.ts b/extensions/sql-migration/src/wizard/wizardController.ts index 70b1e92c12..03d0939db0 100644 --- a/extensions/sql-migration/src/wizard/wizardController.ts +++ b/extensions/sql-migration/src/wizard/wizardController.ts @@ -6,7 +6,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as mssql from '../../../mssql'; import { MigrationStateModel } from '../models/stateMachine'; -import { WIZARD_TITLE } from '../models/strings'; +import * as loc from '../constants/strings'; import { MigrationWizardPage } from '../models/migrationWizardPage'; import { SKURecommendationPage } from './skuRecommendationPage'; // import { SubscriptionSelectionPage } from './subscriptionSelectionPage'; @@ -31,7 +31,7 @@ export class WizardController { } private async createWizard(stateModel: MigrationStateModel): Promise { - const wizard = azdata.window.createWizard(WIZARD_TITLE, 'wide'); + const wizard = azdata.window.createWizard(loc.WIZARD_TITLE, 'wide'); wizard.generateScriptButton.enabled = false; wizard.generateScriptButton.hidden = true; const skuRecommendationPage = new SKURecommendationPage(wizard, stateModel);