diff --git a/extensions/sql-migration/images/addNew.svg b/extensions/sql-migration/images/addNew.svg new file mode 100644 index 0000000000..1ef77155d1 --- /dev/null +++ b/extensions/sql-migration/images/addNew.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-migration/images/allTables.svg b/extensions/sql-migration/images/allTables.svg new file mode 100644 index 0000000000..db28a06bd9 --- /dev/null +++ b/extensions/sql-migration/images/allTables.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-migration/images/breadCrumb.svg b/extensions/sql-migration/images/breadCrumb.svg new file mode 100644 index 0000000000..d8cad89ee2 --- /dev/null +++ b/extensions/sql-migration/images/breadCrumb.svg @@ -0,0 +1,3 @@ + + chevron_right + diff --git a/extensions/sql-migration/package.json b/extensions/sql-migration/package.json index 9b590503f7..07f42c6553 100644 --- a/extensions/sql-migration/package.json +++ b/extensions/sql-migration/package.json @@ -2,7 +2,7 @@ "name": "sql-migration", "displayName": "%displayName%", "description": "%description%", - "version": "1.0.4", + "version": "1.0.5", "publisher": "Microsoft", "preview": false, "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/main/LICENSE.txt", @@ -147,32 +147,7 @@ "when": "connectionProvider == 'MSSQL' && !mssql:iscloud && mssql:engineedition != 8", "hideRefreshTask": true, "container": { - "grid-container": [ - { - "name": "", - "row": 0, - "col": 0, - "widget": { - "tasks-widget": [ - "sqlmigration.start", - "sqlmigration.newsupportrequest", - "sqlmigration.sendfeedback" - ] - } - }, - { - "name": "", - "row": 0, - "col": 1, - "rowspan": 2.5, - "colspan": 3.5, - "widget": { - "modelview": { - "id": "migration.dashboard" - } - } - } - ] + "modelview-container": null } } ] diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index 27d9e6fa46..c519bf2568 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -526,6 +526,22 @@ export interface MigrationStatusDetails { lastRestoredFilename: string; pendingLogBackupsCount: number; invalidFiles: string[]; + listOfCopyProgressDetails: CopyProgressDetail[]; +} + +export interface CopyProgressDetail { + tableName: string; + status: 'PreparingForCopy' | 'Copying' | 'CopyFinished' | 'RebuildingIndexes' | 'Succeeded' | 'Failed' | 'Canceled', + parallelCopyType: string; + usedParallelCopies: number; + dataRead: number; + dataWritten: number; + rowsRead: number; + rowsCopied: number; + copyStart: string; + copyThroughput: number, + copyDuration: number; + errors: string[]; } export interface SqlConnectionInfo { diff --git a/extensions/sql-migration/src/api/utils.ts b/extensions/sql-migration/src/api/utils.ts index 362a53e389..b7cf8826b4 100644 --- a/extensions/sql-migration/src/api/utils.ts +++ b/extensions/sql-migration/src/api/utils.ts @@ -5,13 +5,16 @@ import { window, Account, accounts, CategoryValue, DropDownComponent, IconPath } from 'azdata'; import { IconPathHelper } from '../constants/iconPathHelper'; -import { AdsMigrationStatus } from '../dialog/migrationStatus/migrationStatusDialogModel'; import { MigrationStatus, ProvisioningState } from '../models/migrationLocalStorage'; import * as crypto from 'crypto'; import * as azure from './azure'; import { azureResource, Tenant } from 'azurecore'; import * as constants from '../constants/strings'; import { logError, TelemetryViews } from '../telemtery'; +import { AdsMigrationStatus } from '../dashboard/tabBase'; +import { getMigrationMode, getMigrationStatus, getMigrationTargetType, PipelineStatusCodes } from '../constants/helper'; + +export const DefaultSettingValue = '---'; export function deepClone(obj: T): T { if (!obj || typeof obj !== 'object') { @@ -92,34 +95,68 @@ export function convertTimeDifferenceToDuration(startTime: Date, endTime: Date): } } -export function filterMigrations(databaseMigrations: azure.DatabaseMigration[], statusFilter: string, databaseNameFilter?: string): azure.DatabaseMigration[] { - let filteredMigration: azure.DatabaseMigration[] = []; - if (statusFilter === AdsMigrationStatus.ALL) { - filteredMigration = databaseMigrations; - } else if (statusFilter === AdsMigrationStatus.ONGOING) { - filteredMigration = databaseMigrations.filter( - value => { - const status = value.properties?.migrationStatus; - return status === MigrationStatus.InProgress - || status === MigrationStatus.Creating - || value.properties?.provisioningState === MigrationStatus.Creating; - }); - } else if (statusFilter === AdsMigrationStatus.SUCCEEDED) { - filteredMigration = databaseMigrations.filter( - value => value.properties?.migrationStatus === MigrationStatus.Succeeded); - } else if (statusFilter === AdsMigrationStatus.FAILED) { - filteredMigration = databaseMigrations.filter( - value => - value.properties?.migrationStatus === MigrationStatus.Failed || - value.properties?.provisioningState === ProvisioningState.Failed); - } else if (statusFilter === AdsMigrationStatus.COMPLETING) { - filteredMigration = databaseMigrations.filter( - value => value.properties?.migrationStatus === MigrationStatus.Completing); +export function getMigrationTime(migrationTime: string): string { + return migrationTime + ? new Date(migrationTime).toLocaleString() + : DefaultSettingValue; +} + +export function getMigrationDuration(startDate: string, endDate: string): string { + if (startDate) { + if (endDate) { + return convertTimeDifferenceToDuration( + new Date(startDate), + new Date(endDate)); + } else { + return convertTimeDifferenceToDuration( + new Date(startDate), + new Date()); + } } - if (databaseNameFilter) { - const filter = databaseNameFilter.toLowerCase(); + + return DefaultSettingValue; +} + +export function filterMigrations(databaseMigrations: azure.DatabaseMigration[], statusFilter: string, columnTextFilter?: string): azure.DatabaseMigration[] { + let filteredMigration: azure.DatabaseMigration[] = databaseMigrations || []; + if (columnTextFilter) { + const filter = columnTextFilter.toLowerCase(); filteredMigration = filteredMigration.filter( - migration => migration.name?.toLowerCase().includes(filter)); + migration => migration.properties.sourceServerName?.toLowerCase().includes(filter) + || migration.properties.sourceDatabaseName?.toLowerCase().includes(filter) + || getMigrationStatus(migration)?.toLowerCase().includes(filter) + || getMigrationMode(migration)?.toLowerCase().includes(filter) + || getMigrationTargetType(migration)?.toLowerCase().includes(filter) + || azure.getResourceName(migration.properties.scope)?.toLowerCase().includes(filter) + || azure.getResourceName(migration.id)?.toLowerCase().includes(filter) + || getMigrationDuration( + migration.properties.startedOn, + migration.properties.endedOn)?.toLowerCase().includes(filter) + || getMigrationTime(migration.properties.startedOn)?.toLowerCase().includes(filter) + || getMigrationTime(migration.properties.endedOn)?.toLowerCase().includes(filter) + || getMigrationMode(migration)?.toLowerCase().includes(filter)); + } + + switch (statusFilter) { + case AdsMigrationStatus.ALL: + return filteredMigration; + case AdsMigrationStatus.ONGOING: + return filteredMigration.filter( + value => { + const status = getMigrationStatus(value); + return status === MigrationStatus.InProgress + || status === MigrationStatus.Retriable + || status === MigrationStatus.Creating; + }); + case AdsMigrationStatus.SUCCEEDED: + return filteredMigration.filter( + value => getMigrationStatus(value) === MigrationStatus.Succeeded); + case AdsMigrationStatus.FAILED: + return filteredMigration.filter( + value => getMigrationStatus(value) === MigrationStatus.Failed); + case AdsMigrationStatus.COMPLETING: + return filteredMigration.filter( + value => getMigrationStatus(value) === MigrationStatus.Completing); } return filteredMigration; } @@ -208,12 +245,61 @@ export function decorate(decorator: (fn: Function, key: string) => Function): Fu } export function getSessionIdHeader(sessionId: string): { [key: string]: string } { - return { - 'SqlMigrationSessionId': sessionId - }; + return { 'SqlMigrationSessionId': sessionId }; } -export function getMigrationStatusImage(status: string): IconPath { +export function getMigrationStatusWithErrors(migration: azure.DatabaseMigration): string { + const properties = migration.properties; + const migrationStatus = properties.migrationStatus ?? properties.provisioningState; + let warningCount = 0; + + if (properties.migrationFailureError?.message) { + warningCount++; + } + if (properties.migrationStatusDetails?.fileUploadBlockingErrors) { + const blockingErrors = properties.migrationStatusDetails?.fileUploadBlockingErrors.length ?? 0; + warningCount += blockingErrors; + } + if (properties.migrationStatusDetails?.restoreBlockingReason) { + warningCount++; + } + + return constants.STATUS_VALUE(migrationStatus, warningCount) + + (constants.STATUS_WARNING_COUNT(migrationStatus, warningCount) ?? ''); +} + +export function getPipelineStatusImage(status: string | undefined): IconPath { + // status codes: 'PreparingForCopy' | 'Copying' | 'CopyFinished' | 'RebuildingIndexes' | 'Succeeded' | 'Failed' | 'Canceled', + switch (status) { + case PipelineStatusCodes.Copying: // Copying: 'Copying', + return IconPathHelper.copy; + case PipelineStatusCodes.CopyFinished: // CopyFinished: 'CopyFinished', + case PipelineStatusCodes.RebuildingIndexes: // RebuildingIndexes: 'RebuildingIndexes', + return IconPathHelper.inProgressMigration; + case PipelineStatusCodes.Canceled: // Canceled: 'Canceled', + return IconPathHelper.cancel; + case PipelineStatusCodes.PreparingForCopy: // PreparingForCopy: 'PreparingForCopy', + return IconPathHelper.notStartedMigration; + case PipelineStatusCodes.Failed: // Failed: 'Failed', + return IconPathHelper.error; + case PipelineStatusCodes.Succeeded: // Succeeded: 'Succeeded', + return IconPathHelper.completedMigration; + + // legacy status codes: Queued: 'Queued', InProgress: 'InProgress',Cancelled: 'Cancelled', + case PipelineStatusCodes.Queued: + return IconPathHelper.notStartedMigration; + case PipelineStatusCodes.InProgress: + return IconPathHelper.inProgressMigration; + case PipelineStatusCodes.Cancelled: + return IconPathHelper.cancel; + // default: + default: + return IconPathHelper.error; + } +} + +export function getMigrationStatusImage(migration: azure.DatabaseMigration): IconPath { + const status = getMigrationStatus(migration); switch (status) { case MigrationStatus.InProgress: return IconPathHelper.inProgressMigration; @@ -223,7 +309,10 @@ export function getMigrationStatusImage(status: string): IconPath { return IconPathHelper.notStartedMigration; case MigrationStatus.Completing: return IconPathHelper.completingCutover; + case MigrationStatus.Retriable: + return IconPathHelper.retry; case MigrationStatus.Canceling: + case MigrationStatus.Canceled: return IconPathHelper.cancel; case MigrationStatus.Failed: default: diff --git a/extensions/sql-migration/src/constants/helper.ts b/extensions/sql-migration/src/constants/helper.ts index 1c20d22848..90d032b9a7 100644 --- a/extensions/sql-migration/src/constants/helper.ts +++ b/extensions/sql-migration/src/constants/helper.ts @@ -15,8 +15,84 @@ export enum SQLTargetAssetType { SQLDB = 'Microsoft.Sql/servers', } -export function getMigrationTargetType(migration: DatabaseMigration): string { - const id = migration.id?.toLowerCase(); +export const ParallelCopyTypeCodes = { + None: 'None', + DynamicRange: 'DynamicRange', + PhysicalPartitionsOfTable: 'PhysicalPartitionsOfTable', +}; + +export const PipelineStatusCodes = { + // status codes: 'PreparingForCopy' | 'Copying' | 'CopyFinished' | 'RebuildingIndexes' | 'Succeeded' | 'Failed' | 'Canceled', + PreparingForCopy: 'PreparingForCopy', + Copying: 'Copying', + CopyFinished: 'CopyFinished', + RebuildingIndexes: 'RebuildingIndexes', + Succeeded: 'Succeeded', + Failed: 'Failed', + Canceled: 'Canceled', + + // legacy status codes + Queued: 'Queued', + InProgress: 'InProgress', + Cancelled: 'Cancelled', +}; + +const _dateFormatter = new Intl.DateTimeFormat( + undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' +}); + +const _numberFormatter = new Intl.NumberFormat( + undefined, { + style: 'decimal', + useGrouping: true, + minimumIntegerDigits: 1, + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export function formatDateTimeString(dateTime: string): string { + return dateTime + ? _dateFormatter.format(new Date(dateTime)) + : ''; +} + +export function formatTime(miliseconds: number): string { + if (miliseconds > 0) { + // hh:mm:ss + const matches = (new Date(miliseconds))?.toUTCString()?.match(/(\d\d:\d\d:\d\d)/) || []; + return matches?.length > 0 ? matches[0] : ''; + } + return ''; +} + +export function formatNumber(value: number): string { + return value >= 0 + ? _numberFormatter.format(value) + : ''; +} + +export function formatCopyThroughPut(value: number): string { + return value >= 0 + ? loc.sizeFormatter.format(value / 1024) + : ''; +} + +export function formatSizeBytes(sizeBytes: number): string { + return formatSizeKb(sizeBytes / 1024); +} + +export function formatSizeKb(sizeKb: number): string { + return loc.formatSizeMb(sizeKb / 1024); +} + +export function getMigrationTargetType(migration: DatabaseMigration | undefined): string { + const id = migration?.id?.toLowerCase() || ''; if (id?.indexOf(SQLTargetAssetType.SQLMI.toLowerCase()) > -1) { return loc.SQL_MANAGED_INSTANCE; } @@ -29,8 +105,8 @@ export function getMigrationTargetType(migration: DatabaseMigration): string { return ''; } -export function getMigrationTargetTypeEnum(migration: DatabaseMigration): MigrationTargetType | undefined { - switch (migration.type) { +export function getMigrationTargetTypeEnum(migration: DatabaseMigration | undefined): MigrationTargetType | undefined { + switch (migration?.type) { case SQLTargetAssetType.SQLMI: return MigrationTargetType.SQLMI; case SQLTargetAssetType.SQLVM: @@ -42,37 +118,86 @@ export function getMigrationTargetTypeEnum(migration: DatabaseMigration): Migrat } } -export function getMigrationMode(migration: DatabaseMigration): string { +export function getMigrationMode(migration: DatabaseMigration | undefined): string { return isOfflineMigation(migration) ? loc.OFFLINE : loc.ONLINE; } -export function getMigrationModeEnum(migration: DatabaseMigration): MigrationMode { +export function getMigrationModeEnum(migration: DatabaseMigration | undefined): MigrationMode { return isOfflineMigation(migration) ? MigrationMode.OFFLINE : MigrationMode.ONLINE; } -export function isOfflineMigation(migration: DatabaseMigration): boolean { - return migration.properties.offlineConfiguration?.offline === true; +export function isOfflineMigation(migration: DatabaseMigration | undefined): boolean { + return migration?.properties?.offlineConfiguration?.offline === true; } -export function isBlobMigration(migration: DatabaseMigration): boolean { +export function isBlobMigration(migration: DatabaseMigration | undefined): boolean { return migration?.properties?.backupConfiguration?.sourceLocation?.fileStorageType === FileStorageType.AzureBlob; } -export function getMigrationStatus(migration: DatabaseMigration): string { - return migration.properties.migrationStatus - ?? migration.properties.provisioningState; +export function getMigrationStatus(migration: DatabaseMigration | undefined): string | undefined { + return migration?.properties.migrationStatus + ?? migration?.properties.provisioningState; } +export function hasMigrationOperationId(migration: DatabaseMigration | undefined): boolean { + const migrationId = migration?.id ?? ''; + const migationOperationId = migration?.properties?.migrationOperationId ?? ''; + return migrationId.length > 0 + && migationOperationId.length > 0; +} -export function canRetryMigration(status: string | undefined): boolean { - return status === undefined || - status === MigrationStatus.Failed || - status === MigrationStatus.Succeeded || - status === MigrationStatus.Canceled; +export function canCancelMigration(migration: DatabaseMigration | undefined): boolean { + const status = getMigrationStatus(migration); + return hasMigrationOperationId(migration) + && (status === MigrationStatus.InProgress || + status === MigrationStatus.Retriable || + status === MigrationStatus.Creating); +} + +export function canDeleteMigration(migration: DatabaseMigration | undefined): boolean { + const status = getMigrationStatus(migration); + return status === MigrationStatus.Canceled + || status === MigrationStatus.Failed + || status === MigrationStatus.Retriable + || status === MigrationStatus.Succeeded; +} + +export function canRetryMigration(migration: DatabaseMigration | undefined): boolean { + const status = getMigrationStatus(migration); + return status === MigrationStatus.Canceled + || status === MigrationStatus.Retriable + || status === MigrationStatus.Failed + || status === MigrationStatus.Succeeded; +} + +export function canCutoverMigration(migration: DatabaseMigration | undefined): boolean { + const status = getMigrationStatus(migration); + return hasMigrationOperationId(migration) + && status === MigrationStatus.InProgress + && isOnlineMigration(migration) + && isFullBackupRestored(migration); +} + +export function isActiveMigration(migration: DatabaseMigration | undefined): boolean { + const status = getMigrationStatus(migration); + return status === MigrationStatus.Completing + || status === MigrationStatus.Retriable + || status === MigrationStatus.Creating + || status === MigrationStatus.InProgress; +} + +export function isFullBackupRestored(migration: DatabaseMigration | undefined): boolean { + const fileName = migration?.properties?.migrationStatusDetails?.lastRestoredFilename ?? ''; + return migration?.properties?.migrationStatusDetails?.isFullBackupRestored + || fileName.length > 0; +} + +export function isOnlineMigration(migration: DatabaseMigration | undefined): boolean { + return getMigrationModeEnum(migration) === MigrationMode.ONLINE; } export function selectDatabasesFromList(selectedDbs: string[], databaseTableValues: azdata.DeclarativeTableCellValue[][]): azdata.DeclarativeTableCellValue[][] { diff --git a/extensions/sql-migration/src/constants/iconPathHelper.ts b/extensions/sql-migration/src/constants/iconPathHelper.ts index 5b2d5d8b94..a5450d130b 100644 --- a/extensions/sql-migration/src/constants/iconPathHelper.ts +++ b/extensions/sql-migration/src/constants/iconPathHelper.ts @@ -45,6 +45,9 @@ export class IconPathHelper { public static stop: IconPath; public static view: IconPath; public static sqlMigrationService: IconPath; + public static addNew: IconPath; + public static breadCrumb: IconPath; + public static allTables: IconPath; public static setExtensionContext(context: vscode.ExtensionContext) { IconPathHelper.copy = { @@ -183,5 +186,17 @@ export class IconPathHelper { light: context.asAbsolutePath('images/sqlMigrationService.svg'), dark: context.asAbsolutePath('images/sqlMigrationService.svg'), }; + IconPathHelper.addNew = { + light: context.asAbsolutePath('images/addNew.svg'), + dark: context.asAbsolutePath('images/addNew.svg'), + }; + IconPathHelper.breadCrumb = { + light: context.asAbsolutePath('images/breadCrumb.svg'), + dark: context.asAbsolutePath('images/breadCrumb.svg'), + }; + IconPathHelper.allTables = { + light: context.asAbsolutePath('images/allTables.svg'), + dark: context.asAbsolutePath('images/allTables.svg'), + }; } } diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index fe45aeb2eb..ce85a09edb 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -8,6 +8,7 @@ import * as nls from 'vscode-nls'; import { EOL } from 'os'; import { MigrationStatus } from '../models/migrationLocalStorage'; import { MigrationSourceAuthenticationType } from '../models/stateMachine'; +import { ParallelCopyTypeCodes, PipelineStatusCodes } from './helper'; const localize = nls.loadMessageBundle(); @@ -518,9 +519,9 @@ export const NOTEBOOK_SQL_MIGRATION_ASSESSMENT_TITLE = localize('sql.migration.s export const NOTEBOOK_OPEN_ERROR = localize('sql.migration.notebook.open.error', "Failed to open the migration notebook."); // Dashboard -export function DASHBOARD_REFRESH_MIGRATIONS(error: string): string { - return localize('sql.migration.refresh.migrations.error', "An error occurred while refreshing the migrations list: '{0}'. Please check your linked Azure connection and click refresh to try again.", error); -} +export const DASHBOARD_REFRESH_MIGRATIONS_TITLE = localize('sql.migration.refresh.migrations.error.title', 'An error has occured while refreshing the migrations list.'); +export const DASHBOARD_REFRESH_MIGRATIONS_LABEL = localize('sql.migration.refresh.migrations.error.label', "An error occurred while refreshing the migrations list. Please check your linked Azure connection and click refresh to try again."); + export const DASHBOARD_TITLE = localize('sql.migration.dashboard.title', "Azure SQL Migration"); export const DASHBOARD_DESCRIPTION = localize('sql.migration.dashboard.description', "Determine the migration readiness of your SQL Server instances, identify a recommended Azure SQL target, and complete the migration of your SQL Server instance to Azure SQL Managed Instance or SQL Server on Azure Virtual Machines."); export const DASHBOARD_MIGRATE_TASK_BUTTON_TITLE = localize('sql.migration.dashboard.migrate.task.button', "Migrate to Azure SQL"); @@ -547,6 +548,7 @@ export function MIGRATION_INPROGRESS_WARNING(count: number) { export const FEEDBACK_ISSUE_TITLE = localize('sql.migration.feedback.issue.title', "Feedback on the migration experience"); //Migration cutover dialog +export const BREADCRUMB_MIGRATIONS = localize('sql.migration.details.breadcrumb.migrations', 'Migrations'); export const MIGRATION_CUTOVER = localize('sql.migration.cutover', "Migration cutover"); export const COMPLETE_CUTOVER = localize('sql.migration.complete.cutover', "Complete cutover"); export const SOURCE_DATABASE = localize('sql.migration.source.database', "Source database name"); @@ -588,6 +590,16 @@ export const NA = localize('sql.migration.na', "N/A"); export const EMPTY_TABLE_TEXT = localize('sql.migration.empty.table.text', "No backup files"); export const EMPTY_TABLE_SUBTEXT = localize('sql.migration.empty.table.subtext', "If results were expected, verify the connection to the SQL Server instance."); export const MIGRATION_CUTOVER_ERROR = localize('sql.migration.cutover.error', 'An error occurred while initiating cutover.'); +export const REFRESH_BUTTON_TEXT = localize('sql.migration.details.refresh', 'Refresh'); +export const SERVER_OBJECTS_FIELD_LABEL = localize('sql.migration.details.serverobjects.field.label', 'Server objects'); +export const SERVER_OBJECTS_LABEL = localize('sql.migration.details.serverobjects.label', 'Server objects'); +export const SERVER_OBJECTS_ALL_TABLES_LABEL = localize('sql.migration.details.serverobjects.all.tables.label', 'Total tables'); +export const SERVER_OBJECTS_IN_PROGRESS_TABLES_LABEL = localize('sql.migration.details.serverobjects.inprogress.tables.label', 'In progress'); +export const SERVER_OBJECTS_SUCCESSFUL_TABLES_LABEL = localize('sql.migration.details.serverobjects.successful.tables.label', 'Successful'); +export const SERVER_OBJECTS_FAILED_TABLES_LABEL = localize('sql.migration.details.serverobjects.failed.tables.label', 'Failed'); +export const SERVER_OBJECTS_CANCELLED_TABLES_LABEL = localize('sql.migration.details.serverobjects.cancelled.tables.label', 'Cancelled'); +export const FILTER_SERVER_OBJECTS_PLACEHOLDER = localize('sql.migration.details.serverobjects.filter.label', 'Filter table migration results'); +export const FILTER_SERVER_OBJECTS_ARIA_LABEL = localize('sql.migration.details.serverobjects.filter.aria.label', 'Filter table migration results using keywords'); //Migration confirm cutover dialog export const COMPLETING_CUTOVER_WARNING = localize('sql.migration.completing.cutover.warning', "Completing cutover without restoring all the backups may result in a data loss."); @@ -616,6 +628,7 @@ export const MIGRATION_CANNOT_CUTOVER = localize('sql.migration.cannot.cutover', export const FILE_NAME = localize('sql.migration.file.name', "File name"); export const SIZE_COLUMN_HEADER = localize('sql.migration.size.column.header', "Size"); export const NO_PENDING_BACKUPS = localize('sql.migration.no.pending.backups', "No pending backups. Click refresh to check current status."); + //Migration status dialog export const ADD_ACCOUNT = localize('sql.migration.status.add.account', "Add account"); export const ADD_ACCOUNT_MESSAGE = localize('sql.migration.status.add.account.MESSAGE', "Add your Azure account to view existing migrations and their status."); @@ -625,11 +638,14 @@ export const STATUS_ONGOING = localize('sql.migration.status.dropdown.ongoing', export const STATUS_COMPLETING = localize('sql.migration.status.dropdown.completing', "Status: Completing"); export const STATUS_SUCCEEDED = localize('sql.migration.status.dropdown.succeeded', "Status: Succeeded"); export const STATUS_FAILED = localize('sql.migration.status.dropdown.failed', "Status: Failed"); -export const SEARCH_FOR_MIGRATIONS = localize('sql.migration.search.for.migration', "Search for migrations"); +export const SEARCH_FOR_MIGRATIONS = localize('sql.migration.search.for.migration', "Filter migration results"); export const ONLINE = localize('sql.migration.online', "Online"); export const OFFLINE = localize('sql.migration.offline', "Offline"); export const DATABASE = localize('sql.migration.database', "Database"); -export const STATUS_COLUMN = localize('sql.migration.database.status.column', "Status"); +export const SRC_DATABASE = localize('sql.migration.src.database', "Source database"); +export const SRC_SERVER = localize('sql.migration.src.server', "Source name"); + +export const STATUS_COLUMN = localize('sql.migration.database.status.column', "Migration status"); export const DATABASE_MIGRATION_SERVICE = localize('sql.migration.database.migration.service', "Database Migration Service"); export const DURATION = localize('sql.migration.duration', "Duration"); export const AZURE_SQL_TARGET = localize('sql.migration.azure.sql.target', "Target type"); @@ -637,7 +653,9 @@ export const SQL_MANAGED_INSTANCE = localize('sql.migration.sql.managed.instance export const SQL_VIRTUAL_MACHINE = localize('sql.migration.sql.virtual.machine', "SQL Virtual Machine"); export const SQL_DATABASE = localize('sql.migration.sql.database', "SQL Database"); export const TARGET_AZURE_SQL_INSTANCE_NAME = localize('sql.migration.target.azure.sql.instance.name', "Target name"); -export const MIGRATION_MODE = localize('sql.migration.cutover.type', "Migration mode"); +export const TARGET_SERVER_COLUMN = localize('sql.migration.target.azure.sql.instance.server.name', "Target name"); +export const TARGET_DATABASE_COLUMN = localize('sql.migration.target.azure.sql.instance.database.name', "Target database"); +export const MIGRATION_MODE = localize('sql.migration.cutover.type', "Mode"); export const START_TIME = localize('sql.migration.start.time', "Start time"); export const FINISH_TIME = localize('sql.migration.finish.time', "Finish time"); @@ -648,20 +666,53 @@ export function STATUS_VALUE(status: string, count: number): string { return localize('sql.migration.status.error.count.none', "{0}", StatusLookup[status] ?? status); } +export const MIGRATION_ERROR_DETAILS_TITLE = localize('sql.migration.error.details.title', "Migration error details"); +export const MIGRATION_ERROR_DETAILS_LABEL = localize('sql.migration.error.details.label', "Migration error(s))"); +export const OPEN_MIGRATION_DETAILS_ERROR = localize('sql.migration.open.migration.destails.error', "Error opening migration details dialog"); +export const OPEN_MIGRATION_TARGET_ERROR = localize('sql.migration.open.migration.target.error', "Error opening migration target"); +export const OPEN_MIGRATION_SERVICE_ERROR = localize('sql.migration.open.migration.service.error', "Error opening migration service dialog"); +export const LOAD_MIGRATION_LIST_ERROR = localize('sql.migration.load.migration.list.error', "Error loading migrations list"); +export const ERROR_DIALOG_CLEAR_BUTTON_LABEL = localize('sql.migration.error.dialog.clear.button.label', "Clear"); + export interface LookupTable { [key: string]: T; } + export const StatusLookup: LookupTable = { - ['InProgress']: localize('sql.migration.status.inprogress', 'In progress'), - ['Succeeded']: localize('sql.migration.status.succeeded', 'Succeeded'), - ['Creating']: localize('sql.migration.status.creating', 'Creating'), - ['Completing']: localize('sql.migration.status.completing', 'Completing'), - ['Canceling']: localize('sql.migration.status.canceling', 'Canceling'), - ['Failed']: localize('sql.migration.status.failed', 'Failed'), + [MigrationStatus.InProgress]: localize('sql.migration.status.inprogress', 'In progress'), + [MigrationStatus.Succeeded]: localize('sql.migration.status.succeeded', 'Succeeded'), + [MigrationStatus.Creating]: localize('sql.migration.status.creating', 'Creating'), + [MigrationStatus.Completing]: localize('sql.migration.status.completing', 'Completing'), + [MigrationStatus.Retriable]: localize('sql.migration.status.retriable', 'Retriable'), + [MigrationStatus.Canceling]: localize('sql.migration.status.canceling', 'Canceling'), + [MigrationStatus.Canceled]: localize('sql.migration.status.canceled', 'Canceled'), + [MigrationStatus.Failed]: localize('sql.migration.status.failed', 'Failed'), default: undefined }; +export const PipelineRunStatus: LookupTable = { + // status codes: ['PreparingForCopy' | 'Copying' | 'CopyFinished' | 'RebuildingIndexes' | 'Succeeded' | 'Failed' | 'Canceled'] + [PipelineStatusCodes.PreparingForCopy]: localize('sql.migration.copy.status.preparingforcopy', 'Preparing'), + [PipelineStatusCodes.Copying]: localize('sql.migration.copy.status.copying', 'Copying'), + [PipelineStatusCodes.CopyFinished]: localize('sql.migration.copy.status.copyfinished', 'Copy finished'), + [PipelineStatusCodes.RebuildingIndexes]: localize('sql.migration.copy.status.rebuildingindexes', 'Rebuilding indexes'), + [PipelineStatusCodes.Succeeded]: localize('sql.migration.copy.status.succeeded', 'Succeeded'), + [PipelineStatusCodes.Failed]: localize('sql.migration.copy.status.failed', 'Failed'), + [PipelineStatusCodes.Canceled]: localize('sql.migration.copy.status.canceled', 'Canceled'), + + // legacy status codes ['Queued', 'InProgress', 'Cancelled'] + [PipelineStatusCodes.Queued]: localize('sql.migration.copy.status.queued', 'Queued'), + [PipelineStatusCodes.InProgress]: localize('sql.migration.copy.status.inprogress', 'In progress'), + [PipelineStatusCodes.Cancelled]: localize('sql.migration.copy.status.cancelled', 'Cancelled'), +}; + +export const ParallelCopyType: LookupTable = { + [ParallelCopyTypeCodes.None]: localize('sql.migration.parallel.copy.type.none', 'None'), + [ParallelCopyTypeCodes.PhysicalPartitionsOfTable]: localize('sql.migration.parallel.copy.type.physical', 'Physical partitions'), + [ParallelCopyTypeCodes.DynamicRange]: localize('sql.migration.parallel.copy.type.dynamic', 'Dynamic range'), +}; + export function STATUS_WARNING_COUNT(status: string, count: number): string | undefined { if (status === MigrationStatus.InProgress || status === MigrationStatus.Creating || @@ -699,6 +750,27 @@ export function SEC(sec: number): string { return localize('sql.migration.sec', "{0} sec", sec); } +export const sizeFormatter = new Intl.NumberFormat( + undefined, { + style: 'decimal', + useGrouping: true, + minimumIntegerDigits: 1, + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +export function formatSizeMb(sizeMb: number): string { + if (isNaN(sizeMb) || sizeMb < 0) { + return ''; + } else if (sizeMb < 1024) { + return localize('sql.migration.size.mb', "{0} MB", sizeFormatter.format(sizeMb)); + } else if (sizeMb < 1024 * 1024) { + return localize('sql.migration.size.gb', "{0} GB", sizeFormatter.format(sizeMb / 1024)); + } else { + return localize('sql.migration.size.tb', "{0} TB", sizeFormatter.format(sizeMb / 1024 / 1024)); + } +} + // SQL Migration Service Details page. export const SQL_MIGRATION_SERVICE_DETAILS_SUB_TITLE = localize('sql.migration.service.details.dialog.title', "Azure Database Migration Service"); export const SQL_MIGRATION_SERVICE_DETAILS_BUTTON_LABEL = localize('sql.migration.service.details.button.label', "Close"); @@ -761,6 +833,9 @@ export function WARNINGS_COUNT(totalCount: number): string { export const AUTHENTICATION_TYPE = localize('sql.migration.authentication.type', "Authentication type"); export const REFRESH_BUTTON_LABEL = localize('sql.migration.status.refresh.label', 'Refresh'); +export const STATUS_LABEL = localize('sql.migration.status.status.label', 'Status'); +export const SORT_LABEL = localize('sql.migration.migration.list.sort.label', 'Sort'); +export const ASCENDING_LABEL = localize('sql.migration.migration.list.ascending.label', 'Ascending'); // Saved Assessment Dialog export const NEXT_LABEL = localize('sql.migration.saved.assessment.next', "Next"); @@ -786,3 +861,46 @@ export function MIGRATION_SERVICE_SERVICE_PROMPT(serviceName: string): string { return localize('sql.migration.service.prompt', '{0} (change)', serviceName); } export const MIGRATION_SERVICE_DESCRIPTION = localize('sql.migration.select.service.description', 'Azure Database Migration Service'); + +// Desktop tabs +export const DESKTOP_MIGRATION_BUTTON_LABEL = localize('sql.migration.tab.button.migration.label', 'New migration'); +export const DESKTOP_MIGRATION_BUTTON_DESCRIPTION = localize('sql.migration.tab.button.migration.description', 'Migrate to Azure SQL'); +export const DESKTOP_SUPPORT_BUTTON_LABEL = localize('sql.migration.tab.button.support.label', 'New support request'); +export const DESKTOP_SUPPORT_BUTTON_DESCRIPTION = localize('sql.migration.tab.button.support.description', 'New support request'); +export const DESKTOP_FEEDBACK_BUTTON_LABEL = localize('sql.migration.tab.button.feedback.label', 'Feedback'); +export const DESKTOP_FEEDBACK_BUTTON_DESCRIPTION = localize('sql.migration.tab.button.feedback.description', 'Feedback'); +export const DESKTOP_DASHBOARD_TAB_TITLE = localize('sql.migration.tab.dashboard.title', 'Dashboard'); +export const DESKTOP_MIGRATIONS_TAB_TITLE = localize('sql.migration.tab.migrations.title', 'Migrations'); + +// dashboard tab +export const DASHBOARD_HELP_LINK_MIGRATE_USING_ADS = localize('sql.migration.dashboard.help.link.migrateUsingADS', 'Migrate databases using Azure Data Studio'); +export const DASHBOARD_HELP_DESCRIPTION_MIGRATE_USING_ADS = localize('sql.migration.dashboard.help.description.migrateUsingADS', 'The Azure SQL Migration extension for Azure Data Studio provides capabilities to assess, get right-sized Azure recommendations and migrate SQL Server databases to Azure.'); +export const DASHBOARD_HELP_LINK_MI_TUTORIAL = localize('sql.migration.dashboard.help.link.mi', 'Tutorial: Migrate to Azure SQL Managed Instance (online)'); +export const DASHBOARD_HELP_DESCRIPTION_MI_TUTORIAL = localize('sql.migration.dashboard.help.description.mi', 'A step-by-step tutorial to migrate databases from a SQL Server instance (on-premises or Azure Virtual Machines) to Azure SQL Managed Instance with minimal downtime.'); +export const DASHBOARD_HELP_LINK_VM_TUTORIAL = localize('sql.migration.dashboard.help.link.vm', 'Tutorial: Migrate to SQL Server on Azure Virtual Machines (online)'); +export const DASHBOARD_HELP_DESCRIPTION_VMTUTORIAL = localize('sql.migration.dashboard.help.description.vm', 'A step-by-step tutorial to migrate databases from a SQL Server instance (on-premises) to SQL Server on Azure Virtual Machines with minimal downtime.'); +export const DASHBOARD_HELP_LINK_DMS_GUIDE = localize('sql.migration.dashboard.help.link.dmsGuide', 'Azure Database Migration Guides'); +export const DASHBOARD_HELP_DESCRIPTION_DMS_GUIDE = localize('sql.migration.dashboard.help.description.dmsGuide', 'A hub of migration articles that provides step-by-step guidance for migrating and modernizing your data assets in Azure.'); + +// Error info +export const DATABASE_MIGRATION_STATUS_TITLE = localize('sql.migration.error.title', 'Migration status details'); +export const TABLE_MIGRATION_STATUS_TITLE = localize('sql.migration.table.error.title', 'Table migration status details'); + +export function DATABASE_MIGRATION_STATUS_LABEL(status?: string): string { + return localize('sql.migration.database.migration.status.label', 'Database migration status: {0}', status ?? ''); +} + +export function TABLE_MIGRATION_STATUS_LABEL(status?: string): string { + return localize('sql.migration.table.migration.status.label', 'Table migration status: {0}', status ?? ''); +} + +export const SQLDB_COL_TABLE_NAME = localize('sql.migration.sqldb.column.tablename', 'Table name'); +export const SQLDB_COL_DATA_READ = localize('sql.migration.sqldb.column.dataread', 'Data read'); +export const SQLDB_COL_DATA_WRITTEN = localize('sql.migration.sqldb.column.datawritten', 'Data written'); +export const SQLDB_COL_ROWS_READ = localize('sql.migration.sqldb.column.rowsread', 'Rows read'); +export const SQLDB_COL_ROWS_COPIED = localize('sql.migration.sqldb.column.rowscopied', 'Rows copied'); +export const SQLDB_COL_COPY_THROUGHPUT = localize('sql.migration.sqldb.column.copythroughput', 'Copy throughput'); +export const SQLDB_COL_COPY_DURATION = localize('sql.migration.sqldb.column.copyduration', 'Copy duration'); +export const SQLDB_COL_PARRALEL_COPY_TYPE = localize('sql.migration.sqldb.column.parallelcopytype', 'Parallel copy type'); +export const SQLDB_COL_USED_PARALLEL_COPIES = localize('sql.migration.sqldb.column.usedparallelcopies', 'Used parallel copies'); +export const SQLDB_COL_COPY_START = localize('sql.migration.sqldb.column.copystart', 'Copy start'); diff --git a/extensions/sql-migration/src/dashboard/dashboardTab.ts b/extensions/sql-migration/src/dashboard/dashboardTab.ts new file mode 100644 index 0000000000..4d91decc68 --- /dev/null +++ b/extensions/sql-migration/src/dashboard/dashboardTab.ts @@ -0,0 +1,789 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { IconPath, IconPathHelper } from '../constants/iconPathHelper'; +import * as styles from '../constants/styles'; +import * as loc from '../constants/strings'; +import { filterMigrations } from '../api/utils'; +import { DatabaseMigration } from '../api/azure'; +import { getCurrentMigrations, getSelectedServiceStatus, isServiceContextValid, MigrationLocalStorage } from '../models/migrationLocalStorage'; +import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog'; +import { logError, TelemetryViews } from '../telemtery'; +import { AdsMigrationStatus, MenuCommands, TabBase } from './tabBase'; +import { DashboardStatusBar } from './sqlServerDashboard'; + +interface IActionMetadata { + title?: string, + description?: string, + link?: string, + iconPath?: azdata.ThemedIconPath, + command?: string; +} + +interface StatusCard { + container: azdata.DivContainer; + count: azdata.TextComponent, + textContainer?: azdata.FlexContainer, + warningContainer?: azdata.FlexContainer, + warningText?: azdata.TextComponent, +} + +export const DashboardTabId = 'DashboardTab'; + +const maxWidth = 800; +const BUTTON_CSS = { + 'font-size': '13px', + 'line-height': '18px', + 'margin': '4px 0', + 'text-align': 'left', +}; + +export class DashboardTab extends TabBase { + private _migrationStatusCardsContainer!: azdata.FlexContainer; + private _migrationStatusCardLoadingContainer!: azdata.LoadingComponent; + private _inProgressMigrationButton!: StatusCard; + private _inProgressWarningMigrationButton!: StatusCard; + private _allMigrationButton!: StatusCard; + private _successfulMigrationButton!: StatusCard; + private _failedMigrationButton!: StatusCard; + private _completingMigrationButton!: StatusCard; + private _selectServiceText!: azdata.TextComponent; + private _serviceContextButton!: azdata.ButtonComponent; + private _refreshButton!: azdata.ButtonComponent; + + constructor() { + super(); + this.title = loc.DESKTOP_DASHBOARD_TAB_TITLE; + this.id = DashboardTabId; + this.icon = IconPathHelper.sqlMigrationLogo; + } + + public onDialogClosed = async (): Promise => + await this.updateServiceContext(this._serviceContextButton); + + public async create( + view: azdata.ModelView, + openMigrationsFcn: (status: AdsMigrationStatus) => Promise, + statusBar: DashboardStatusBar): Promise { + + this.view = view; + this.openMigrationFcn = openMigrationsFcn; + this.statusBar = statusBar; + + await this.initialize(this.view); + + return this; + } + + public async refresh(): Promise { + if (this.isRefreshing) { + return; + } + + this.isRefreshing = true; + this._migrationStatusCardLoadingContainer.loading = true; + let migrations: DatabaseMigration[] = []; + try { + await this.statusBar.clearError(); + migrations = await getCurrentMigrations(); + } catch (e) { + await this.statusBar.showError( + loc.DASHBOARD_REFRESH_MIGRATIONS_TITLE, + loc.DASHBOARD_REFRESH_MIGRATIONS_LABEL, + e.message); + logError(TelemetryViews.SqlServerDashboard, 'RefreshgMigrationFailed', e); + } + + const inProgressMigrations = filterMigrations(migrations, AdsMigrationStatus.ONGOING); + let warningCount = 0; + for (let i = 0; i < inProgressMigrations.length; i++) { + if (inProgressMigrations[i].properties.migrationFailureError?.message || + inProgressMigrations[i].properties.migrationStatusDetails?.fileUploadBlockingErrors || + inProgressMigrations[i].properties.migrationStatusDetails?.restoreBlockingReason) { + warningCount += 1; + } + } + if (warningCount > 0) { + this._inProgressWarningMigrationButton.warningText!.value = loc.MIGRATION_INPROGRESS_WARNING(warningCount); + this._inProgressMigrationButton.container.display = 'none'; + this._inProgressWarningMigrationButton.container.display = ''; + } else { + this._inProgressMigrationButton.container.display = ''; + this._inProgressWarningMigrationButton.container.display = 'none'; + } + + this._inProgressMigrationButton.count.value = inProgressMigrations.length.toString(); + this._inProgressWarningMigrationButton.count.value = inProgressMigrations.length.toString(); + + this._updateStatusCard(migrations, this._successfulMigrationButton, AdsMigrationStatus.SUCCEEDED, true); + this._updateStatusCard(migrations, this._failedMigrationButton, AdsMigrationStatus.FAILED); + this._updateStatusCard(migrations, this._completingMigrationButton, AdsMigrationStatus.COMPLETING); + this._updateStatusCard(migrations, this._allMigrationButton, AdsMigrationStatus.ALL, true); + + await this._updateSummaryStatus(); + this.isRefreshing = false; + this._migrationStatusCardLoadingContainer.loading = false; + } + + protected async initialize(view: azdata.ModelView): Promise { + const container = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + width: '100%', + height: '100%' + }).component(); + + const toolbar = view.modelBuilder.toolbarContainer(); + toolbar.addToolbarItems([ + { component: this.createNewMigrationButton() }, + { component: this.createNewSupportRequestButton() }, + { component: this.createFeedbackButton() }, + ]); + + container.addItem( + toolbar.component(), + { CSSStyles: { 'flex': '0 0 auto' } }); + + const header = this._createHeader(view); + // Files need to have the vscode-file scheme to be loaded by ADS + const watermarkUri = vscode.Uri + .file(IconPathHelper.migrationDashboardHeaderBackground.light) + .with({ scheme: 'vscode-file' }); + + container.addItem(header, { + CSSStyles: { + 'background-image': ` + url(${watermarkUri}), + linear-gradient(0deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0) 100%)`, + 'background-repeat': 'no-repeat', + 'background-position': '91.06% 100%', + 'margin-bottom': '20px' + } + }); + + const tasksContainer = await this._createTasks(view); + header.addItem(tasksContainer, { + CSSStyles: { + 'width': `${maxWidth}px`, + 'margin': '24px' + } + }); + container.addItem( + await this._createFooter(view), + { CSSStyles: { 'margin': '0 24px' } }); + + this.content = container; + } + + private _createHeader(view: azdata.ModelView): azdata.FlexContainer { + const header = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + width: maxWidth, + }).component(); + const titleComponent = view.modelBuilder.text() + .withProps({ + value: loc.DASHBOARD_TITLE, + width: '750px', + CSSStyles: { ...styles.DASHBOARD_TITLE_CSS } + }).component(); + + const descriptionComponent = view.modelBuilder.text() + .withProps({ + value: loc.DASHBOARD_DESCRIPTION, + CSSStyles: { ...styles.NOTE_CSS } + }).component(); + header.addItems([titleComponent, descriptionComponent], { + CSSStyles: { + 'width': `${maxWidth}px`, + 'padding-left': '24px' + } + }); + return header; + } + + private async _createTasks(view: azdata.ModelView): Promise { + const tasksContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + width: '100%', + }).component(); + + const migrateButtonMetadata: IActionMetadata = { + title: loc.DASHBOARD_MIGRATE_TASK_BUTTON_TITLE, + description: loc.DASHBOARD_MIGRATE_TASK_BUTTON_DESCRIPTION, + iconPath: IconPathHelper.sqlMigrationLogo, + command: MenuCommands.StartMigration + }; + + const preRequisiteListTitle = view.modelBuilder.text() + .withProps({ + value: loc.PRE_REQ_TITLE, + CSSStyles: { + ...styles.BODY_CSS, + 'margin': '0px', + } + }).component(); + + const migrateButton = this._createTaskButton(view, migrateButtonMetadata); + + const preRequisiteListElement = view.modelBuilder.text() + .withProps({ + value: [ + loc.PRE_REQ_1, + loc.PRE_REQ_2, + loc.PRE_REQ_3 + ], + CSSStyles: { + ...styles.SMALL_NOTE_CSS, + 'padding-left': '12px', + 'margin': '-0.5em 0px', + } + }).component(); + + const preRequisiteLearnMoreLink = view.modelBuilder.hyperlink() + .withProps({ + label: loc.LEARN_MORE, + ariaLabel: loc.LEARN_MORE_ABOUT_PRE_REQS, + url: 'https://aka.ms/azuresqlmigrationextension', + }).component(); + + const preReqContainer = view.modelBuilder.flexContainer() + .withItems([ + preRequisiteListTitle, + preRequisiteListElement, + preRequisiteLearnMoreLink]) + .withLayout({ flexFlow: 'column' }) + .component(); + + tasksContainer.addItem(migrateButton, {}); + tasksContainer.addItems( + [preReqContainer], + { CSSStyles: { 'margin-left': '20px' } }); + + return tasksContainer; + } + + private _createTaskButton(view: azdata.ModelView, taskMetaData: IActionMetadata): azdata.Component { + const maxHeight: number = 84; + const maxWidth: number = 236; + const buttonContainer = view.modelBuilder.button().withProps({ + buttonType: azdata.ButtonType.Informational, + description: taskMetaData.description, + height: maxHeight, + iconHeight: 32, + iconPath: taskMetaData.iconPath, + iconWidth: 32, + label: taskMetaData.title, + title: taskMetaData.title, + width: maxWidth, + CSSStyles: { + 'border': '1px solid', + 'display': 'flex', + 'flex-direction': 'column', + 'justify-content': 'flex-start', + 'border-radius': '4px', + 'transition': 'all .5s ease', + } + }).component(); + this.disposables.push( + buttonContainer.onDidClick(async () => { + if (taskMetaData.command) { + await vscode.commands.executeCommand(taskMetaData.command); + } + })); + return view.modelBuilder.divContainer().withItems([buttonContainer]).component(); + } + + private async _createFooter(view: azdata.ModelView): Promise { + const footerContainer = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'row', + width: maxWidth, + justifyContent: 'flex-start' + }).component(); + const statusContainer = await this._createMigrationStatusContainer(view); + const videoLinksContainer = this._createVideoLinks(view); + footerContainer.addItem(statusContainer); + footerContainer.addItem( + videoLinksContainer, + { CSSStyles: { 'padding-left': '8px', } }); + + return footerContainer; + } + + private _createVideoLinks(view: azdata.ModelView): azdata.Component { + const linksContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + width: '440px', + height: '365px', + justifyContent: 'flex-start', + }).withProps({ + CSSStyles: { + 'border': '1px solid rgba(0, 0, 0, 0.1)', + 'padding': '10px', + 'overflow': 'scroll', + } + }).component(); + + const titleComponent = view.modelBuilder.text() + .withProps({ + value: loc.HELP_TITLE, + CSSStyles: { ...styles.SECTION_HEADER_CSS } + }) + .component(); + + linksContainer.addItems( + [titleComponent], + { CSSStyles: { 'margin-bottom': '16px' } }); + + const links = [ + { + title: loc.DASHBOARD_HELP_LINK_MIGRATE_USING_ADS, + description: loc.DASHBOARD_HELP_DESCRIPTION_MIGRATE_USING_ADS, + link: 'https://docs.microsoft.com/azure/dms/migration-using-azure-data-studio' + }, + { + title: loc.DASHBOARD_HELP_LINK_MI_TUTORIAL, + description: loc.DASHBOARD_HELP_DESCRIPTION_MI_TUTORIAL, + link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-managed-instance-online-ads' + }, + { + title: loc.DASHBOARD_HELP_LINK_VM_TUTORIAL, + description: loc.DASHBOARD_HELP_DESCRIPTION_VMTUTORIAL, + link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-to-virtual-machine-online-ads' + }, + { + title: loc.DASHBOARD_HELP_LINK_DMS_GUIDE, + description: loc.DASHBOARD_HELP_DESCRIPTION_DMS_GUIDE, + link: 'https://docs.microsoft.com/data-migration/' + }, + ]; + + linksContainer.addItems(links.map(l => this._createLink(view, l)), {}); + + const videoLinks: IActionMetadata[] = []; + const videosContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + width: maxWidth, + }).component(); + videosContainer.addItems(videoLinks.map(l => this._createVideoLink(view, l)), {}); + linksContainer.addItem(videosContainer); + + return linksContainer; + } + + private _createLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component { + const maxWidth = 400; + const labelsContainer = view.modelBuilder.flexContainer() + .withProps({ + CSSStyles: { + 'flex-direction': 'column', + 'width': `${maxWidth}px`, + 'justify-content': 'flex-start', + 'margin-bottom': '12px' + } + }).component(); + const linkContainer = view.modelBuilder.flexContainer() + .withProps({ + CSSStyles: { + 'flex-direction': 'row', + 'width': `${maxWidth}px`, + 'justify-content': 'flex-start', + 'margin-bottom': '4px' + } + + }).component(); + const descriptionComponent = view.modelBuilder.text() + .withProps({ + value: linkMetaData.description, + width: maxWidth, + CSSStyles: { ...styles.NOTE_CSS } + }).component(); + const linkComponent = view.modelBuilder.hyperlink() + .withProps({ + label: linkMetaData.title!, + url: linkMetaData.link!, + showLinkIcon: true, + CSSStyles: { ...styles.BODY_CSS } + }).component(); + linkContainer.addItem(linkComponent); + labelsContainer.addItems([linkContainer, descriptionComponent]); + return labelsContainer; + } + + private _createVideoLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component { + const maxWidth = 150; + const videosContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + width: maxWidth, + justifyContent: 'flex-start' + }).component(); + const video1Container = view.modelBuilder.divContainer() + .withProps({ + clickable: true, + width: maxWidth, + height: '100px' + }).component(); + const descriptionComponent = view.modelBuilder.text() + .withProps({ + value: linkMetaData.description, + width: maxWidth, + height: '50px', + CSSStyles: { ...styles.BODY_CSS } + }).component(); + this.disposables.push( + video1Container.onDidClick(async () => { + if (linkMetaData.link) { + await vscode.env.openExternal(vscode.Uri.parse(linkMetaData.link)); + } + })); + videosContainer.addItem(video1Container, { + CSSStyles: { + 'background-image': `url(${vscode.Uri.file(linkMetaData.iconPath?.light)})`, + 'background-repeat': 'no-repeat', + 'background-position': 'top', + 'width': `${maxWidth}px`, + 'height': '104px', + 'background-size': `${maxWidth}px 120px` + } + }); + videosContainer.addItem(descriptionComponent); + return videosContainer; + } + + private _createStatusCard( + view: azdata.ModelView, + cardIconPath: IconPath, + cardTitle: string, + hasSubtext: boolean = false + ): StatusCard { + const buttonWidth = '400px'; + const buttonHeight = hasSubtext ? '70px' : '50px'; + const statusCard = view.modelBuilder.flexContainer() + .withProps({ + CSSStyles: { + 'width': buttonWidth, + 'height': buttonHeight, + 'align-items': 'center', + } + }).component(); + + const statusIcon = view.modelBuilder.image() + .withProps({ + iconPath: cardIconPath!.light, + iconHeight: 24, + iconWidth: 24, + height: 32, + CSSStyles: { 'margin': '0 8px' } + }).component(); + + const textContainer = view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + + const cardTitleText = view.modelBuilder.text() + .withProps({ value: cardTitle }) + .withProps({ + CSSStyles: { + ...styles.SECTION_HEADER_CSS, + 'width': '240px', + } + }).component(); + textContainer.addItem(cardTitleText); + + const cardCount = view.modelBuilder.text() + .withProps({ + value: '0', + CSSStyles: { + ...styles.BIG_NUMBER_CSS, + 'margin': '0 0 0 8px', + 'text-align': 'center', + } + }).component(); + + let warningContainer; + let warningText; + if (hasSubtext) { + const warningIcon = view.modelBuilder.image() + .withProps({ + iconPath: IconPathHelper.warning, + iconWidth: 12, + iconHeight: 12, + width: 12, + height: 18, + }).component(); + + const warningDescription = ''; + warningText = view.modelBuilder.text() + .withProps({ + value: warningDescription, + CSSStyles: { + ...styles.BODY_CSS, + 'padding-left': '8px', + } + }).component(); + + warningContainer = view.modelBuilder.flexContainer() + .withItems( + [warningIcon, warningText], + { flex: '0 0 auto' }) + .withProps({ CSSStyles: { 'align-items': 'center' } }) + .component(); + + textContainer.addItem(warningContainer); + } + + statusCard.addItems([ + statusIcon, + textContainer, + cardCount, + ]); + + const compositeButton = view.modelBuilder.divContainer() + .withItems([statusCard]) + .withProps({ + ariaRole: 'button', + ariaLabel: loc.SHOW_STATUS, + clickable: true, + CSSStyles: { + 'height': buttonHeight, + 'margin-bottom': '16px', + 'border': '1px solid', + 'display': 'flex', + 'flex-direction': 'column', + 'justify-content': 'flex-start', + 'border-radius': '4px', + 'transition': 'all .5s ease', + } + }).component(); + return { + container: compositeButton, + count: cardCount, + textContainer: textContainer, + warningContainer: warningContainer, + warningText: warningText + }; + } + + private async _createMigrationStatusContainer(view: azdata.ModelView): Promise { + const statusContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + width: '400px', + height: '365px', + justifyContent: 'flex-start', + }) + .withProps({ + CSSStyles: { + 'border': '1px solid rgba(0, 0, 0, 0.1)', + 'padding': '10px', + } + }) + .component(); + + const statusContainerTitle = view.modelBuilder.text() + .withProps({ + value: loc.DATABASE_MIGRATION_STATUS, + width: '100%', + CSSStyles: { ...styles.SECTION_HEADER_CSS } + }).component(); + + this._refreshButton = view.modelBuilder.button() + .withProps({ + label: loc.REFRESH, + iconPath: IconPathHelper.refresh, + iconHeight: 16, + iconWidth: 16, + width: 70, + CSSStyles: { 'float': 'right' } + }).component(); + + const statusHeadingContainer = view.modelBuilder.flexContainer() + .withItems([ + statusContainerTitle, + this._refreshButton, + ]).withLayout({ + alignContent: 'center', + alignItems: 'center', + flexFlow: 'row', + }).component(); + + this.disposables.push( + this._refreshButton.onDidClick(async (e) => { + this._refreshButton.enabled = false; + await this.refresh(); + this._refreshButton.enabled = true; + })); + + const buttonContainer = view.modelBuilder.flexContainer() + .withProps({ + CSSStyles: { + 'justify-content': 'left', + 'align-iems': 'center', + }, + }) + .component(); + + buttonContainer.addItem( + await this._createServiceSelector(view)); + + this._selectServiceText = view.modelBuilder.text() + .withProps({ + value: loc.SELECT_SERVICE_MESSAGE, + CSSStyles: { + 'font-size': '12px', + 'margin': '10px', + 'font-weight': '350', + 'text-align': 'center', + 'display': 'none' + } + }).component(); + + const header = view.modelBuilder.flexContainer() + .withItems([statusHeadingContainer, buttonContainer]) + .withLayout({ flexFlow: 'column', }) + .component(); + + this._migrationStatusCardsContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + height: '272px', + }) + .withProps({ CSSStyles: { 'overflow': 'hidden auto' } }) + .component(); + + await this._updateSummaryStatus(); + + // in progress + this._inProgressMigrationButton = this._createStatusCard( + view, + IconPathHelper.inProgressMigration, + loc.MIGRATION_IN_PROGRESS); + this.disposables.push( + this._inProgressMigrationButton.container.onDidClick( + async (e) => await this.openMigrationFcn(AdsMigrationStatus.ONGOING))); + this._migrationStatusCardsContainer.addItem( + this._inProgressMigrationButton.container, + { flex: '0 0 auto' }); + + // in progress warning + this._inProgressWarningMigrationButton = this._createStatusCard( + view, + IconPathHelper.inProgressMigration, + loc.MIGRATION_IN_PROGRESS, + true); + this.disposables.push( + this._inProgressWarningMigrationButton.container.onDidClick( + async (e) => await this.openMigrationFcn(AdsMigrationStatus.ONGOING))); + this._migrationStatusCardsContainer.addItem( + this._inProgressWarningMigrationButton.container, + { flex: '0 0 auto' }); + + // successful + this._successfulMigrationButton = this._createStatusCard( + view, + IconPathHelper.completedMigration, + loc.MIGRATION_COMPLETED); + this.disposables.push( + this._successfulMigrationButton.container.onDidClick( + async (e) => await this.openMigrationFcn(AdsMigrationStatus.SUCCEEDED))); + this._migrationStatusCardsContainer.addItem( + this._successfulMigrationButton.container, + { flex: '0 0 auto' }); + + // completing + this._completingMigrationButton = this._createStatusCard( + view, + IconPathHelper.completingCutover, + loc.MIGRATION_CUTOVER_CARD); + this.disposables.push( + this._completingMigrationButton.container.onDidClick( + async (e) => await this.openMigrationFcn(AdsMigrationStatus.COMPLETING))); + this._migrationStatusCardsContainer.addItem( + this._completingMigrationButton.container, + { flex: '0 0 auto' }); + + // failed + this._failedMigrationButton = this._createStatusCard( + view, + IconPathHelper.error, + loc.MIGRATION_FAILED); + this.disposables.push( + this._failedMigrationButton.container.onDidClick( + async (e) => await this.openMigrationFcn(AdsMigrationStatus.FAILED))); + this._migrationStatusCardsContainer.addItem( + this._failedMigrationButton.container, + { flex: '0 0 auto' }); + + // all migrations + this._allMigrationButton = this._createStatusCard( + view, + IconPathHelper.view, + loc.VIEW_ALL); + this.disposables.push( + this._allMigrationButton.container.onDidClick( + async (e) => await this.openMigrationFcn(AdsMigrationStatus.ALL))); + this._migrationStatusCardsContainer.addItem( + this._allMigrationButton.container, + { flex: '0 0 auto' }); + + this._migrationStatusCardLoadingContainer = view.modelBuilder.loadingComponent() + .withItem(this._migrationStatusCardsContainer) + .component(); + statusContainer.addItem(header, { CSSStyles: { 'margin-bottom': '10px' } }); + statusContainer.addItem(this._selectServiceText, {}); + statusContainer.addItem(this._migrationStatusCardLoadingContainer, {}); + return statusContainer; + } + + private async _createServiceSelector(view: azdata.ModelView): Promise { + const serviceContextLabel = await getSelectedServiceStatus(); + this._serviceContextButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.sqlMigrationService, + iconHeight: 22, + iconWidth: 22, + label: serviceContextLabel, + title: serviceContextLabel, + description: loc.MIGRATION_SERVICE_DESCRIPTION, + buttonType: azdata.ButtonType.Informational, + width: 375, + CSSStyles: { ...BUTTON_CSS }, + }) + .component(); + + this.disposables.push( + this._serviceContextButton.onDidClick(async () => { + const dialog = new SelectMigrationServiceDialog(async () => await this.onDialogClosed()); + await dialog.initialize(); + })); + + return this._serviceContextButton; + } + + private _updateStatusCard( + migrations: DatabaseMigration[], + card: StatusCard, + status: AdsMigrationStatus, + show?: boolean): void { + const list = filterMigrations(migrations, status); + const count = list?.length || 0; + card.container.display = count > 0 || show ? '' : 'none'; + card.count.value = count.toString(); + } + + private async _updateSummaryStatus(): Promise { + const serviceContext = await MigrationLocalStorage.getMigrationServiceContext(); + const isContextValid = isServiceContextValid(serviceContext); + await this._selectServiceText.updateCssStyles({ 'display': isContextValid ? 'none' : 'block' }); + await this._migrationStatusCardsContainer.updateCssStyles({ 'visibility': isContextValid ? 'visible' : 'hidden' }); + this._refreshButton.enabled = isContextValid; + } +} diff --git a/extensions/sql-migration/src/dashboard/migrationDetailsBlobContainerTab.ts b/extensions/sql-migration/src/dashboard/migrationDetailsBlobContainerTab.ts new file mode 100644 index 0000000000..3508ec2753 --- /dev/null +++ b/extensions/sql-migration/src/dashboard/migrationDetailsBlobContainerTab.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as loc from '../constants/strings'; +import { getSqlServerName, getMigrationStatusImage } from '../api/utils'; +import { logError, TelemetryViews } from '../telemtery'; +import { canCancelMigration, canCutoverMigration, canRetryMigration, getMigrationStatus, getMigrationTargetTypeEnum, isOfflineMigation } from '../constants/helper'; +import { getResourceName } from '../api/azure'; +import { InfoFieldSchema, infoFieldWidth, MigrationDetailsTabBase, MigrationTargetTypeName } from './migrationDetailsTabBase'; +import { EmptySettingValue } from './tabBase'; +import { DashboardStatusBar } from './sqlServerDashboard'; + +const MigrationDetailsBlobContainerTabId = 'MigrationDetailsBlobContainerTab'; + +export class MigrationDetailsBlobContainerTab extends MigrationDetailsTabBase { + private _sourceDatabaseInfoField!: InfoFieldSchema; + private _sourceDetailsInfoField!: InfoFieldSchema; + private _sourceVersionInfoField!: InfoFieldSchema; + private _targetDatabaseInfoField!: InfoFieldSchema; + private _targetServerInfoField!: InfoFieldSchema; + private _targetVersionInfoField!: InfoFieldSchema; + private _migrationStatusInfoField!: InfoFieldSchema; + private _backupLocationInfoField!: InfoFieldSchema; + private _lastAppliedBackupInfoField!: InfoFieldSchema; + private _currentRestoringFileInfoField!: InfoFieldSchema; + + constructor() { + super(); + this.id = MigrationDetailsBlobContainerTabId; + } + + public async create( + context: vscode.ExtensionContext, + view: azdata.ModelView, + onClosedCallback: () => Promise, + statusBar: DashboardStatusBar, + ): Promise { + + this.view = view; + this.context = context; + this.onClosedCallback = onClosedCallback; + this.statusBar = statusBar; + + await this.initialize(this.view); + + return this; + } + + public async refresh(): Promise { + if (this.isRefreshing || this.model?.migration === undefined) { + return; + } + + this.isRefreshing = true; + this.refreshButton.enabled = false; + this.refreshLoader.loading = true; + await this.statusBar.clearError(); + + try { + await this.model.fetchStatus(); + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_STATUS_REFRESH_ERROR, + loc.MIGRATION_STATUS_REFRESH_ERROR, + e.message); + } + + const migration = this.model?.migration; + await this.cutoverButton.updateCssStyles( + { 'display': isOfflineMigation(migration) ? 'none' : 'block' }); + + await this.showMigrationErrors(migration); + + const sqlServerName = migration.properties.sourceServerName; + const sourceDatabaseName = migration.properties.sourceDatabaseName; + const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId); + const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!); + const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion; + const targetDatabaseName = migration.name; + const targetServerName = getResourceName(migration.properties.scope); + + const targetType = getMigrationTargetTypeEnum(migration); + const targetServerVersion = MigrationTargetTypeName[targetType ?? '']; + + this.databaseLabel.value = sourceDatabaseName; + this._sourceDatabaseInfoField.text.value = sourceDatabaseName; + this._sourceDetailsInfoField.text.value = sqlServerName; + this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`; + + this._targetDatabaseInfoField.text.value = targetDatabaseName; + this._targetServerInfoField.text.value = targetServerName; + this._targetVersionInfoField.text.value = targetServerVersion; + + this._migrationStatusInfoField.text.value = getMigrationStatus(migration) ?? EmptySettingValue; + this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migration); + + const storageAccountResourceId = migration.properties.backupConfiguration?.sourceLocation?.azureBlob?.storageAccountResourceId; + const blobContainerName + = migration.properties.backupConfiguration?.sourceLocation?.azureBlob?.blobContainerName + ?? migration.properties.migrationStatusDetails?.blobContainerName; + + const backupLocation = storageAccountResourceId && blobContainerName + ? `${storageAccountResourceId?.split('/').pop()} - ${blobContainerName}` + : blobContainerName; + this._backupLocationInfoField.text.value = backupLocation ?? EmptySettingValue; + this._lastAppliedBackupInfoField.text.value = migration.properties.migrationStatusDetails?.lastRestoredFilename ?? EmptySettingValue; + this._currentRestoringFileInfoField.text.value = this.getMigrationCurrentlyRestoringFile(migration) ?? EmptySettingValue; + + this.cutoverButton.enabled = canCutoverMigration(migration); + this.cancelButton.enabled = canCancelMigration(migration); + this.retryButton.enabled = canRetryMigration(migration); + + this.isRefreshing = false; + this.refreshLoader.loading = false; + this.refreshButton.enabled = true; + } + + protected async initialize(view: azdata.ModelView): Promise { + try { + const formItems: azdata.FormComponent[] = [ + { component: this.createMigrationToolbarContainer() }, + { component: await this.migrationInfoGrid() }, + { + component: this.view.modelBuilder.separator() + .withProps({ width: '100%', CSSStyles: { 'padding': '0' } }) + .component() + }, + ]; + + this.content = this.view.modelBuilder.formContainer() + .withFormItems( + formItems, + { horizontal: false }) + .withLayout({ width: '100%', padding: '0 0 0 15px' }) + .withProps({ width: '100%', CSSStyles: { padding: '0 0 0 15px' } }) + .component(); + } catch (e) { + logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e); + } + } + + protected override async migrationInfoGrid(): Promise { + const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => { + container.addItem( + infoField.flexContainer, + { CSSStyles: { width: infoFieldWidth } }); + }; + + const flexServer = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, ''); + this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, ''); + this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, ''); + addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer); + addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer); + addInfoFieldToContainer(this._sourceVersionInfoField, flexServer); + + const flexTarget = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, ''); + this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, ''); + this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, ''); + addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget); + addInfoFieldToContainer(this._targetServerInfoField, flexTarget); + addInfoFieldToContainer(this._targetVersionInfoField, flexTarget); + + const flexStatus = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' '); + this._backupLocationInfoField = await this.createInfoField(loc.BACKUP_LOCATION, ''); + addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus); + addInfoFieldToContainer(this._backupLocationInfoField, flexStatus); + + const flexFile = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._lastAppliedBackupInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, ''); + this._currentRestoringFileInfoField = await this.createInfoField(loc.CURRENTLY_RESTORING_FILE, '', false); + addInfoFieldToContainer(this._lastAppliedBackupInfoField, flexFile); + addInfoFieldToContainer(this._currentRestoringFileInfoField, flexFile); + + const flexInfoProps = { + flex: '0', + CSSStyles: { 'flex': '0', 'width': infoFieldWidth } + }; + + const flexInfo = this.view.modelBuilder.flexContainer() + .withLayout({ flexWrap: 'wrap' }) + .withProps({ width: '100%' }) + .component(); + flexInfo.addItem(flexServer, flexInfoProps); + flexInfo.addItem(flexTarget, flexInfoProps); + flexInfo.addItem(flexStatus, flexInfoProps); + flexInfo.addItem(flexFile, flexInfoProps); + + return flexInfo; + } +} diff --git a/extensions/sql-migration/src/dashboard/migrationDetailsFileShareTab.ts b/extensions/sql-migration/src/dashboard/migrationDetailsFileShareTab.ts new file mode 100644 index 0000000000..4912fd027a --- /dev/null +++ b/extensions/sql-migration/src/dashboard/migrationDetailsFileShareTab.ts @@ -0,0 +1,389 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { IconPathHelper } from '../constants/iconPathHelper'; +import * as loc from '../constants/strings'; +import { convertByteSizeToReadableUnit, convertIsoTimeToLocalTime, getSqlServerName, getMigrationStatusImage } from '../api/utils'; +import { logError, TelemetryViews } from '../telemtery'; +import * as styles from '../constants/styles'; +import { canCancelMigration, canCutoverMigration, canRetryMigration, getMigrationStatus, getMigrationTargetTypeEnum, isOfflineMigation } from '../constants/helper'; +import { getResourceName } from '../api/azure'; +import { EmptySettingValue } from './tabBase'; +import { InfoFieldSchema, infoFieldWidth, MigrationDetailsTabBase, MigrationTargetTypeName } from './migrationDetailsTabBase'; +import { DashboardStatusBar } from './sqlServerDashboard'; + +const MigrationDetailsFileShareTabId = 'MigrationDetailsFileShareTab'; + +interface ActiveBackupFileSchema { + fileName: string, + type: string, + status: string, + dataUploaded: string, + copyThroughput: string, + backupStartTime: string, + firstLSN: string, + lastLSN: string +} + +export class MigrationDetailsFileShareTab extends MigrationDetailsTabBase { + private _sourceDatabaseInfoField!: InfoFieldSchema; + private _sourceDetailsInfoField!: InfoFieldSchema; + private _sourceVersionInfoField!: InfoFieldSchema; + private _targetDatabaseInfoField!: InfoFieldSchema; + private _targetServerInfoField!: InfoFieldSchema; + private _targetVersionInfoField!: InfoFieldSchema; + private _migrationStatusInfoField!: InfoFieldSchema; + private _fullBackupFileOnInfoField!: InfoFieldSchema; + private _backupLocationInfoField!: InfoFieldSchema; + private _lastLSNInfoField!: InfoFieldSchema; + private _lastAppliedBackupInfoField!: InfoFieldSchema; + private _lastAppliedBackupTakenOnInfoField!: InfoFieldSchema; + private _currentRestoringFileInfoField!: InfoFieldSchema; + + private _fileCount!: azdata.TextComponent; + private _fileTable!: azdata.TableComponent; + private _emptyTableFill!: azdata.FlexContainer; + + constructor() { + super(); + this.id = MigrationDetailsFileShareTabId; + } + + public async create( + context: vscode.ExtensionContext, + view: azdata.ModelView, + onClosedCallback: () => Promise, + statusBar: DashboardStatusBar): Promise { + + this.view = view; + this.context = context; + this.onClosedCallback = onClosedCallback; + this.statusBar = statusBar; + + await this.initialize(this.view); + + return this; + } + + public async refresh(): Promise { + if (this.isRefreshing || this.model?.migration === undefined) { + return; + } + + this.isRefreshing = true; + this.refreshButton.enabled = false; + this.refreshLoader.loading = true; + await this.statusBar.clearError(); + await this._fileTable.updateProperty('data', []); + + try { + await this.model.fetchStatus(); + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_STATUS_REFRESH_ERROR, + loc.MIGRATION_STATUS_REFRESH_ERROR, + e.message); + } + + const migration = this.model?.migration; + await this.cutoverButton.updateCssStyles( + { 'display': isOfflineMigation(migration) ? 'none' : 'block' }); + + await this.showMigrationErrors(migration); + + const sqlServerName = migration.properties.sourceServerName; + const sourceDatabaseName = migration.properties.sourceDatabaseName; + const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId); + const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!); + const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion; + const targetDatabaseName = migration.name; + const targetServerName = getResourceName(migration.properties.scope); + + const targetType = getMigrationTargetTypeEnum(migration); + const targetServerVersion = MigrationTargetTypeName[targetType ?? '']; + + let lastAppliedSSN: string; + let lastAppliedBackupFileTakenOn: string; + + const tableData: ActiveBackupFileSchema[] = []; + migration.properties.migrationStatusDetails?.activeBackupSets?.forEach( + (activeBackupSet) => { + tableData.push( + ...activeBackupSet.listOfBackupFiles.map(f => { + return { + fileName: f.fileName, + type: activeBackupSet.backupType, + status: f.status, + dataUploaded: `${convertByteSizeToReadableUnit(f.dataWritten)} / ${convertByteSizeToReadableUnit(f.totalSize)}`, + copyThroughput: (f.copyThroughput) ? (f.copyThroughput / 1024).toFixed(2) : EmptySettingValue, + backupStartTime: activeBackupSet.backupStartDate, + firstLSN: activeBackupSet.firstLSN, + lastLSN: activeBackupSet.lastLSN + }; + }) + ); + + if (activeBackupSet.listOfBackupFiles[0].fileName === migration.properties.migrationStatusDetails?.lastRestoredFilename) { + lastAppliedSSN = activeBackupSet.lastLSN; + lastAppliedBackupFileTakenOn = activeBackupSet.backupFinishDate; + } + }); + + this.databaseLabel.value = sourceDatabaseName; + this._sourceDatabaseInfoField.text.value = sourceDatabaseName; + this._sourceDetailsInfoField.text.value = sqlServerName; + this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`; + + this._targetDatabaseInfoField.text.value = targetDatabaseName; + this._targetServerInfoField.text.value = targetServerName; + this._targetVersionInfoField.text.value = targetServerVersion; + + this._migrationStatusInfoField.text.value = getMigrationStatus(migration) ?? EmptySettingValue; + this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migration); + + this._fullBackupFileOnInfoField.text.value = migration?.properties?.migrationStatusDetails?.fullBackupSetInfo?.listOfBackupFiles[0]?.fileName! ?? EmptySettingValue; + + const fileShare = migration.properties.backupConfiguration?.sourceLocation?.fileShare; + const backupLocation = fileShare?.path! ?? EmptySettingValue; + this._backupLocationInfoField.text.value = backupLocation ?? EmptySettingValue; + + this._lastLSNInfoField.text.value = lastAppliedSSN! ?? EmptySettingValue; + this._lastAppliedBackupInfoField.text.value = migration.properties.migrationStatusDetails?.lastRestoredFilename ?? EmptySettingValue; + this._lastAppliedBackupTakenOnInfoField.text.value = lastAppliedBackupFileTakenOn! ? convertIsoTimeToLocalTime(lastAppliedBackupFileTakenOn).toLocaleString() : EmptySettingValue; + this._currentRestoringFileInfoField.text.value = this.getMigrationCurrentlyRestoringFile(migration) ?? EmptySettingValue; + + await this._fileCount.updateCssStyles({ ...styles.SECTION_HEADER_CSS, display: 'inline' }); + + this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length); + if (tableData.length === 0) { + await this._emptyTableFill.updateCssStyles({ 'display': 'flex' }); + this._fileTable.height = '50px'; + await this._fileTable.updateProperty('data', []); + } else { + await this._emptyTableFill.updateCssStyles({ 'display': 'none' }); + this._fileTable.height = '300px'; + + // Sorting files in descending order of backupStartTime + tableData.sort((file1, file2) => new Date(file1.backupStartTime) > new Date(file2.backupStartTime) ? - 1 : 1); + } + + const data = tableData.map(row => [ + row.fileName, + row.type, + row.status, + row.dataUploaded, + row.copyThroughput, + convertIsoTimeToLocalTime(row.backupStartTime).toLocaleString(), + row.firstLSN, + row.lastLSN + ]) || []; + + await this._fileTable.updateProperty('data', data); + + this.cutoverButton.enabled = canCutoverMigration(migration); + this.cancelButton.enabled = canCancelMigration(migration); + this.retryButton.enabled = canRetryMigration(migration); + this.isRefreshing = false; + this.refreshLoader.loading = false; + this.refreshButton.enabled = true; + } + + protected async initialize(view: azdata.ModelView): Promise { + try { + this._fileCount = this.view.modelBuilder.text() + .withProps({ + width: '500px', + CSSStyles: { ...styles.BODY_CSS } + }).component(); + + this._fileTable = this.view.modelBuilder.table() + .withProps({ + ariaLabel: loc.ACTIVE_BACKUP_FILES, + CSSStyles: { 'padding-left': '0px', 'max-width': '1020px' }, + data: [], + height: '300px', + columns: [ + { + value: 'files', + name: loc.ACTIVE_BACKUP_FILES, + type: azdata.ColumnType.text, + width: 230, + }, + { + value: 'type', + name: loc.TYPE, + width: 90, + type: azdata.ColumnType.text, + }, + { + value: 'status', + name: loc.STATUS, + width: 60, + type: azdata.ColumnType.text, + }, + { + value: 'uploaded', + name: loc.DATA_UPLOADED, + width: 120, + type: azdata.ColumnType.text, + }, + { + value: 'throughput', + name: loc.COPY_THROUGHPUT, + width: 150, + type: azdata.ColumnType.text, + }, + { + value: 'starttime', + name: loc.BACKUP_START_TIME, + width: 130, + type: azdata.ColumnType.text, + }, + { + value: 'firstlsn', + name: loc.FIRST_LSN, + width: 120, + type: azdata.ColumnType.text, + }, + { + value: 'lastlsn', + name: loc.LAST_LSN, + width: 120, + type: azdata.ColumnType.text, + } + ], + }).component(); + + const emptyTableImage = this.view.modelBuilder.image() + .withProps({ + iconPath: IconPathHelper.emptyTable, + iconHeight: '100px', + iconWidth: '100px', + height: '100px', + width: '100px', + CSSStyles: { 'text-align': 'center' } + }).component(); + + const emptyTableText = this.view.modelBuilder.text() + .withProps({ + value: loc.EMPTY_TABLE_TEXT, + CSSStyles: { + ...styles.NOTE_CSS, + 'margin-top': '8px', + 'text-align': 'center', + 'width': '300px' + } + }).component(); + + this._emptyTableFill = this.view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + alignItems: 'center' + }).withItems([ + emptyTableImage, + emptyTableText, + ]).withProps({ + width: '100%', + display: 'none' + }).component(); + + const formItems: azdata.FormComponent[] = [ + { component: this.createMigrationToolbarContainer() }, + { component: await this.migrationInfoGrid() }, + { + component: this.view.modelBuilder.separator() + .withProps({ width: '100%', CSSStyles: { 'padding': '0' } }) + .component() + }, + { component: this._fileCount }, + { component: this._fileTable }, + { component: this._emptyTableFill } + ]; + + const formContainer = this.view.modelBuilder.formContainer() + .withFormItems( + formItems, + { horizontal: false }) + .withLayout({ width: '100%', padding: '0 0 0 15px' }) + .withProps({ width: '100%', CSSStyles: { padding: '0 0 0 15px' } }) + .component(); + + this.content = formContainer; + } catch (e) { + logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e); + } + } + + protected async migrationInfoGrid(): Promise { + const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => { + container.addItem( + infoField.flexContainer, + { CSSStyles: { width: infoFieldWidth } }); + }; + + const flexServer = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, ''); + this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, ''); + this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, ''); + addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer); + addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer); + addInfoFieldToContainer(this._sourceVersionInfoField, flexServer); + + const flexTarget = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, ''); + this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, ''); + this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, ''); + addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget); + addInfoFieldToContainer(this._targetServerInfoField, flexTarget); + addInfoFieldToContainer(this._targetVersionInfoField, flexTarget); + + const flexStatus = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' '); + this._fullBackupFileOnInfoField = await this.createInfoField(loc.FULL_BACKUP_FILES, '', false); + this._backupLocationInfoField = await this.createInfoField(loc.BACKUP_LOCATION, ''); + addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus); + addInfoFieldToContainer(this._fullBackupFileOnInfoField, flexStatus); + addInfoFieldToContainer(this._backupLocationInfoField, flexStatus); + + const flexFile = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._lastLSNInfoField = await this.createInfoField(loc.LAST_APPLIED_LSN, '', false); + this._lastAppliedBackupInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, ''); + this._lastAppliedBackupTakenOnInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES_TAKEN_ON, '', false); + this._currentRestoringFileInfoField = await this.createInfoField(loc.CURRENTLY_RESTORING_FILE, '', false); + addInfoFieldToContainer(this._lastLSNInfoField, flexFile); + addInfoFieldToContainer(this._lastAppliedBackupInfoField, flexFile); + addInfoFieldToContainer(this._lastAppliedBackupTakenOnInfoField, flexFile); + addInfoFieldToContainer(this._currentRestoringFileInfoField, flexFile); + + const flexInfoProps = { + flex: '0', + CSSStyles: { + 'flex': '0', + 'width': infoFieldWidth + } + }; + + const flexInfo = this.view.modelBuilder.flexContainer() + .withLayout({ flexWrap: 'wrap' }) + .withProps({ width: '100%' }) + .component(); + flexInfo.addItem(flexServer, flexInfoProps); + flexInfo.addItem(flexTarget, flexInfoProps); + flexInfo.addItem(flexStatus, flexInfoProps); + flexInfo.addItem(flexFile, flexInfoProps); + + return flexInfo; + } +} diff --git a/extensions/sql-migration/src/dashboard/migrationDetailsTabBase.ts b/extensions/sql-migration/src/dashboard/migrationDetailsTabBase.ts new file mode 100644 index 0000000000..e2bdd589e6 --- /dev/null +++ b/extensions/sql-migration/src/dashboard/migrationDetailsTabBase.ts @@ -0,0 +1,463 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { IconPathHelper } from '../constants/iconPathHelper'; +import { MigrationServiceContext } from '../models/migrationLocalStorage'; +import * as loc from '../constants/strings'; +import * as styles from '../constants/styles'; +import { DatabaseMigration } from '../api/azure'; +import { TabBase } from './tabBase'; +import { MigrationCutoverDialogModel } from '../dialog/migrationCutover/migrationCutoverDialogModel'; +import { ConfirmCutoverDialog } from '../dialog/migrationCutover/confirmCutoverDialog'; +import { RetryMigrationDialog } from '../dialog/retryMigration/retryMigrationDialog'; +import { MigrationTargetType } from '../models/stateMachine'; +import { DashboardStatusBar } from './sqlServerDashboard'; + +export const infoFieldLgWidth: string = '330px'; +export const infoFieldWidth: string = '250px'; + +const statusImageSize: number = 14; + +export const MigrationTargetTypeName: loc.LookupTable = { + [MigrationTargetType.SQLMI]: loc.AZURE_SQL_DATABASE_MANAGED_INSTANCE, + [MigrationTargetType.SQLVM]: loc.AZURE_SQL_DATABASE_VIRTUAL_MACHINE, + [MigrationTargetType.SQLDB]: loc.AZURE_SQL_DATABASE, +}; + +export interface InfoFieldSchema { + flexContainer: azdata.FlexContainer, + text: azdata.TextComponent, + icon?: azdata.ImageComponent, +} + +export abstract class MigrationDetailsTabBase extends TabBase { + protected model!: MigrationCutoverDialogModel; + protected databaseLabel!: azdata.TextComponent; + protected serviceContext!: MigrationServiceContext; + protected onClosedCallback!: () => Promise; + + protected cutoverButton!: azdata.ButtonComponent; + protected refreshButton!: azdata.ButtonComponent; + protected cancelButton!: azdata.ButtonComponent; + protected refreshLoader!: azdata.LoadingComponent; + protected copyDatabaseMigrationDetails!: azdata.ButtonComponent; + protected newSupportRequest!: azdata.ButtonComponent; + protected retryButton!: azdata.ButtonComponent; + protected summaryTextComponent: azdata.TextComponent[] = []; + + public abstract create(context: vscode.ExtensionContext, view: azdata.ModelView, onClosedCallback: () => Promise, statusBar: DashboardStatusBar): Promise; + + protected abstract migrationInfoGrid(): Promise; + + constructor() { + super(); + this.title = ''; + } + + public async setMigrationContext( + serviceContext: MigrationServiceContext, + migration: DatabaseMigration): Promise { + this.serviceContext = serviceContext; + this.model = new MigrationCutoverDialogModel(serviceContext, migration); + await this.refresh(); + } + + protected createBreadcrumbContainer(): azdata.FlexContainer { + const migrationsTabLink = this.view.modelBuilder.hyperlink() + .withProps({ + label: loc.BREADCRUMB_MIGRATIONS, + url: '', + title: loc.BREADCRUMB_MIGRATIONS, + CSSStyles: { + 'padding': '5px 5px 5px 0', + 'font-size': '13px' + } + }) + .component(); + this.disposables.push( + migrationsTabLink.onDidClick( + async (e) => await this.onClosedCallback())); + + const breadCrumbImage = this.view.modelBuilder.image() + .withProps({ + iconPath: IconPathHelper.breadCrumb, + iconHeight: 8, + iconWidth: 8, + width: 8, + height: 8, + CSSStyles: { 'padding': '4px' } + }).component(); + + this.databaseLabel = this.view.modelBuilder.text() + .withProps({ + textType: azdata.TextType.Normal, + value: '...', + CSSStyles: { + 'font-size': '16px', + 'font-weight': '600', + 'margin-block-start': '0', + 'margin-block-end': '0', + } + }).component(); + + return this.view.modelBuilder.flexContainer() + .withItems( + [migrationsTabLink, breadCrumbImage, this.databaseLabel], + { flex: '0 0 auto' }) + .withLayout({ + flexFlow: 'row', + alignItems: 'center', + alignContent: 'center', + }) + .withProps({ + height: 20, + CSSStyles: { 'padding': '0', 'margin-bottom': '5px' } + }) + .component(); + } + + protected createMigrationToolbarContainer(): azdata.FlexContainer { + const toolbarContainer = this.view.modelBuilder.toolbarContainer(); + const buttonHeight = 20; + this.cutoverButton = this.view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.cutover, + iconHeight: '16px', + iconWidth: '16px', + label: loc.COMPLETE_CUTOVER, + height: buttonHeight, + enabled: false, + CSSStyles: { 'display': 'none' } + }).component(); + + this.disposables.push( + this.cutoverButton.onDidClick(async (e) => { + await this.statusBar.clearError(); + await this.refresh(); + const dialog = new ConfirmCutoverDialog(this.model); + await dialog.initialize(); + + if (this.model.CutoverError) { + await this.statusBar.showError( + loc.MIGRATION_CUTOVER_ERROR, + loc.MIGRATION_CUTOVER_ERROR, + this.model.CutoverError.message); + } + })); + + this.cancelButton = this.view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.cancel, + iconHeight: '16px', + iconWidth: '16px', + label: loc.CANCEL_MIGRATION, + height: buttonHeight, + enabled: false, + }).component(); + + this.disposables.push( + this.cancelButton.onDidClick((e) => { + void vscode.window.showInformationMessage( + loc.CANCEL_MIGRATION_CONFIRMATION, + { modal: true }, + loc.YES, + loc.NO + ).then(async (v) => { + if (v === loc.YES) { + await this.statusBar.clearError(); + await this.model.cancelMigration(); + await this.refresh(); + if (this.model.CancelMigrationError) { + { + await this.statusBar.showError( + loc.MIGRATION_CANCELLATION_ERROR, + loc.MIGRATION_CANCELLATION_ERROR, + this.model.CancelMigrationError.message); + } + } + } + }); + })); + + + this.retryButton = this.view.modelBuilder.button() + .withProps({ + label: loc.RETRY_MIGRATION, + iconPath: IconPathHelper.retry, + enabled: false, + iconHeight: '16px', + iconWidth: '16px', + height: buttonHeight, + }).component(); + + this.disposables.push( + this.retryButton.onDidClick( + async (e) => { + await this.refresh(); + const retryMigrationDialog = new RetryMigrationDialog( + this.context, + this.serviceContext, + this.model.migration, + this.onClosedCallback); + await retryMigrationDialog.openDialog(); + } + )); + + this.copyDatabaseMigrationDetails = this.view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.copy, + iconHeight: '16px', + iconWidth: '16px', + label: loc.COPY_MIGRATION_DETAILS, + height: buttonHeight, + }).component(); + + this.disposables.push( + this.copyDatabaseMigrationDetails.onDidClick(async (e) => { + await this.refresh(); + await vscode.env.clipboard.writeText(this._getMigrationDetails()); + + void vscode.window.showInformationMessage(loc.DETAILS_COPIED); + })); + + this.newSupportRequest = this.view.modelBuilder.button() + .withProps({ + label: loc.NEW_SUPPORT_REQUEST, + iconPath: IconPathHelper.newSupportRequest, + iconHeight: '16px', + iconWidth: '16px', + height: buttonHeight, + }).component(); + + this.disposables.push( + this.newSupportRequest.onDidClick(async (e) => { + const serviceId = this.model.migration.properties.migrationService; + const supportUrl = `https://portal.azure.com/#resource${serviceId}/supportrequest`; + await vscode.env.openExternal(vscode.Uri.parse(supportUrl)); + })); + + this.refreshButton = this.view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.refresh, + iconHeight: '16px', + iconWidth: '16px', + label: loc.REFRESH_BUTTON_TEXT, + height: buttonHeight, + }).component(); + + this.disposables.push( + this.refreshButton.onDidClick( + async (e) => await this.refresh())); + + this.refreshLoader = this.view.modelBuilder.loadingComponent() + .withProps({ + loading: false, + CSSStyles: { + 'height': '8px', + 'margin-top': '4px' + } + }).component(); + + toolbarContainer.addToolbarItems([ + { component: this.cutoverButton }, + { component: this.cancelButton }, + { component: this.retryButton }, + { component: this.copyDatabaseMigrationDetails, toolbarSeparatorAfter: true }, + { component: this.newSupportRequest, toolbarSeparatorAfter: true }, + { component: this.refreshButton }, + { component: this.refreshLoader }, + ]); + + return this.view.modelBuilder.flexContainer() + .withItems([ + this.createBreadcrumbContainer(), + toolbarContainer.component(), + ]) + .withLayout({ flexFlow: 'column', width: '100%' }) + .component(); + } + + protected async createInfoCard( + label: string, + iconPath: azdata.IconPath + ): Promise { + const defaultValue = (0).toLocaleString(); + const flexContainer = this.view.modelBuilder.flexContainer() + .withProps({ + width: 168, + CSSStyles: { + 'flex-direction': 'column', + 'margin': '0 12px 0 0', + 'box-sizing': 'border-box', + 'border': '1px solid rgba(204, 204, 204, 0.5)', + 'box-shadow': '0px 2px 4px rgba(0, 0, 0, 0.1)', + 'border-radius': '2px', + } + }).component(); + + const labelComponent = this.view.modelBuilder.text() + .withProps({ + value: label, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '600', + 'margin': '5px', + } + }).component(); + flexContainer.addItem(labelComponent); + + const iconComponent = this.view.modelBuilder.image() + .withProps({ + iconPath: iconPath, + iconHeight: 16, + iconWidth: 16, + height: 16, + width: 16, + CSSStyles: { + 'margin': '5px 5px 5px 5px', + 'padding': '0' + } + }).component(); + + const textComponent = this.view.modelBuilder.text() + .withProps({ + value: defaultValue, + title: defaultValue, + CSSStyles: { + 'font-size': '20px', + 'font-weight': '600', + 'margin': '0 5px 0 5px' + } + }).component(); + + this.summaryTextComponent.push(textComponent); + + const iconTextComponent = this.view.modelBuilder.flexContainer() + .withItems([iconComponent, textComponent]) + .withLayout({ alignItems: 'center' }) + .withProps({ + CSSStyles: { + 'flex-direction': 'row', + 'margin': '0 0 0 5px', + 'padding': '0', + }, + display: 'inline-flex' + }).component(); + + flexContainer.addItem(iconTextComponent); + + return flexContainer; + } + + protected async createInfoField(label: string, value: string, defaultHidden: boolean = false, iconPath?: azdata.IconPath): Promise<{ + flexContainer: azdata.FlexContainer, + text: azdata.TextComponent, + icon?: azdata.ImageComponent + }> { + const flexContainer = this.view.modelBuilder.flexContainer() + .withProps({ + CSSStyles: { + 'flex-direction': 'column', + 'padding-right': '12px' + } + }).component(); + + const labelComponent = this.view.modelBuilder.text() + .withProps({ + value: label, + CSSStyles: { + ...styles.LIGHT_LABEL_CSS, + 'margin-bottom': '0', + } + }).component(); + flexContainer.addItem(labelComponent); + + const textComponent = this.view.modelBuilder.text() + .withProps({ + value: value, + title: value, + description: value, + width: '100%', + CSSStyles: { + 'font-size': '13px', + 'line-height': '18px', + 'margin': '4px 0 12px', + 'overflow': 'hidden', + 'text-overflow': 'ellipsis', + 'max-width': '230px', + 'display': 'inline-block', + } + }).component(); + + let iconComponent; + if (iconPath) { + iconComponent = this.view.modelBuilder.image() + .withProps({ + iconPath: (iconPath === ' ') ? undefined : iconPath, + iconHeight: statusImageSize, + iconWidth: statusImageSize, + height: statusImageSize, + width: statusImageSize, + title: value, + CSSStyles: { + 'margin': '7px 3px 0 0', + 'padding': '0' + } + }).component(); + + const iconTextComponent = this.view.modelBuilder.flexContainer() + .withItems([ + iconComponent, + textComponent + ]).withProps({ + CSSStyles: { + 'margin': '0', + 'padding': '0' + }, + display: 'inline-flex' + }).component(); + flexContainer.addItem(iconTextComponent); + } else { + flexContainer.addItem(textComponent); + } + + return { + flexContainer: flexContainer, + text: textComponent, + icon: iconComponent + }; + } + + protected async showMigrationErrors(migration: DatabaseMigration): Promise { + const errorMessage = this.getMigrationErrors(migration); + if (errorMessage?.length > 0) { + await this.statusBar.showError( + loc.MIGRATION_ERROR_DETAILS_TITLE, + loc.MIGRATION_ERROR_DETAILS_LABEL, + errorMessage); + } + } + + protected getMigrationCurrentlyRestoringFile(migration: DatabaseMigration): string | undefined { + const lastAppliedBackupFile = this.getMigrationLastAppliedBackupFile(migration); + const currentRestoringFile = migration?.properties?.migrationStatusDetails?.currentRestoringFilename; + + return currentRestoringFile === lastAppliedBackupFile + && currentRestoringFile && currentRestoringFile.length > 0 + ? loc.ALL_BACKUPS_RESTORED + : currentRestoringFile; + } + + protected getMigrationLastAppliedBackupFile(migration: DatabaseMigration): string | undefined { + return migration?.properties?.migrationStatusDetails?.lastRestoredFilename + || migration?.properties?.offlineConfiguration?.lastBackupName; + } + + private _getMigrationDetails(): string { + return JSON.stringify(this.model.migration, undefined, 2); + } +} diff --git a/extensions/sql-migration/src/dashboard/migrationDetailsTableTab.ts b/extensions/sql-migration/src/dashboard/migrationDetailsTableTab.ts new file mode 100644 index 0000000000..fab2b4395c --- /dev/null +++ b/extensions/sql-migration/src/dashboard/migrationDetailsTableTab.ts @@ -0,0 +1,567 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as loc from '../constants/strings'; +import { getSqlServerName, getMigrationStatusImage, getPipelineStatusImage, debounce } from '../api/utils'; +import { logError, TelemetryViews } from '../telemtery'; +import { canCancelMigration, canCutoverMigration, canRetryMigration, formatDateTimeString, formatNumber, formatSizeBytes, formatSizeKb, formatTime, getMigrationStatus, getMigrationTargetTypeEnum, isOfflineMigation, PipelineStatusCodes } from '../constants/helper'; +import { CopyProgressDetail, getResourceName } from '../api/azure'; +import { InfoFieldSchema, infoFieldLgWidth, MigrationDetailsTabBase, MigrationTargetTypeName } from './migrationDetailsTabBase'; +import { EmptySettingValue } from './tabBase'; +import { IconPathHelper } from '../constants/iconPathHelper'; +import { DashboardStatusBar } from './sqlServerDashboard'; +import { EOL } from 'os'; + +const MigrationDetailsTableTabId = 'MigrationDetailsTableTab'; + +const TableColumns = { + tableName: 'tableName', + status: 'status', + dataRead: 'dataRead', + dataWritten: 'dataWritten', + rowsRead: 'rowsRead', + rowsCopied: 'rowsCopied', + copyThroughput: 'copyThroughput', + copyDuration: 'copyDuration', + parallelCopyType: 'parallelCopyType', + usedParallelCopies: 'usedParallelCopies', + copyStart: 'copyStart', +}; + +enum SummaryCardIndex { + TotalTables = 0, + InProgressTables = 1, + SuccessfulTables = 2, + FailedTables = 3, + CanceledTables = 4, +} + +export class MigrationDetailsTableTab extends MigrationDetailsTabBase { + private _sourceDatabaseInfoField!: InfoFieldSchema; + private _sourceDetailsInfoField!: InfoFieldSchema; + private _sourceVersionInfoField!: InfoFieldSchema; + private _targetDatabaseInfoField!: InfoFieldSchema; + private _targetServerInfoField!: InfoFieldSchema; + private _targetVersionInfoField!: InfoFieldSchema; + private _migrationStatusInfoField!: InfoFieldSchema; + private _serverObjectsInfoField!: InfoFieldSchema; + private _tableFilterInputBox!: azdata.InputBoxComponent; + private _columnSortDropdown!: azdata.DropDownComponent; + private _columnSortCheckbox!: azdata.CheckBoxComponent; + private _progressTable!: azdata.TableComponent; + private _progressDetail: CopyProgressDetail[] = []; + + constructor() { + super(); + this.id = MigrationDetailsTableTabId; + } + + public async create( + context: vscode.ExtensionContext, + view: azdata.ModelView, + onClosedCallback: () => Promise, + statusBar: DashboardStatusBar): Promise { + + this.view = view; + this.context = context; + this.onClosedCallback = onClosedCallback; + this.statusBar = statusBar; + + await this.initialize(this.view); + + return this; + } + + @debounce(500) + public async refresh(): Promise { + if (this.isRefreshing) { + return; + } + + this.isRefreshing = true; + this.refreshButton.enabled = false; + this.refreshLoader.loading = true; + await this.statusBar.clearError(); + + try { + await this.model.fetchStatus(); + await this._loadData(); + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_STATUS_REFRESH_ERROR, + loc.MIGRATION_STATUS_REFRESH_ERROR, + e.message); + } + + this.isRefreshing = false; + this.refreshLoader.loading = false; + this.refreshButton.enabled = true; + } + + private async _loadData(): Promise { + const migration = this.model?.migration; + await this.showMigrationErrors(this.model?.migration); + + await this.cutoverButton.updateCssStyles( + { 'display': isOfflineMigation(migration) ? 'none' : 'block' }); + + const sqlServerName = migration?.properties.sourceServerName; + const sourceDatabaseName = migration?.properties.sourceDatabaseName; + const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId); + const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!); + const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion; + const targetDatabaseName = migration?.name; + const targetServerName = getResourceName(migration?.properties.scope); + + const targetType = getMigrationTargetTypeEnum(migration); + const targetServerVersion = MigrationTargetTypeName[targetType ?? '']; + + const hashSet: loc.LookupTable = {}; + this._progressDetail = migration?.properties.migrationStatusDetails?.listOfCopyProgressDetails ?? []; + await this._populateTableData(hashSet); + + const successCount = hashSet[PipelineStatusCodes.Succeeded] ?? 0; + const cancelledCount = + (hashSet[PipelineStatusCodes.Canceled] ?? 0) + + (hashSet[PipelineStatusCodes.Cancelled] ?? 0); + + const failedCount = hashSet[PipelineStatusCodes.Failed] ?? 0; + const inProgressCount = + (hashSet[PipelineStatusCodes.Queued] ?? 0) + + (hashSet[PipelineStatusCodes.CopyFinished] ?? 0) + + (hashSet[PipelineStatusCodes.Copying] ?? 0) + + (hashSet[PipelineStatusCodes.PreparingForCopy] ?? 0) + + (hashSet[PipelineStatusCodes.RebuildingIndexes] ?? 0) + + (hashSet[PipelineStatusCodes.InProgress] ?? 0); + + const totalCount = migration.properties.migrationStatusDetails?.listOfCopyProgressDetails.length ?? 0; + + this._updateSummaryComponent(SummaryCardIndex.TotalTables, totalCount); + this._updateSummaryComponent(SummaryCardIndex.InProgressTables, inProgressCount); + this._updateSummaryComponent(SummaryCardIndex.SuccessfulTables, successCount); + this._updateSummaryComponent(SummaryCardIndex.FailedTables, failedCount); + this._updateSummaryComponent(SummaryCardIndex.CanceledTables, cancelledCount); + + this.databaseLabel.value = sourceDatabaseName; + this._sourceDatabaseInfoField.text.value = sourceDatabaseName; + this._sourceDetailsInfoField.text.value = sqlServerName; + this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`; + + this._targetDatabaseInfoField.text.value = targetDatabaseName; + this._targetServerInfoField.text.value = targetServerName; + this._targetVersionInfoField.text.value = targetServerVersion; + + this._migrationStatusInfoField.text.value = getMigrationStatus(migration) ?? EmptySettingValue; + this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migration); + this._serverObjectsInfoField.text.value = totalCount.toLocaleString(); + + this.cutoverButton.enabled = canCutoverMigration(migration); + this.cancelButton.enabled = canCancelMigration(migration); + this.retryButton.enabled = canRetryMigration(migration); + } + + private async _populateTableData(hashSet: loc.LookupTable = {}): Promise { + if (this._progressTable.data.length > 0) { + await this._progressTable.updateProperty('data', []); + } + + // Sort table data + this._sortTableMigrations( + this._progressDetail, + (this._columnSortDropdown.value).name, + this._columnSortCheckbox.checked === true); + + const data = this._progressDetail.map((d) => { + hashSet[d.status] = (hashSet[d.status] ?? 0) + 1; + return [ + d.tableName, + { + icon: getPipelineStatusImage(d.status), + title: loc.PipelineRunStatus[d.status] ?? d.status?.toUpperCase(), + }, + formatSizeBytes(d.dataRead), + formatSizeBytes(d.dataWritten), + formatNumber(d.rowsRead), + formatNumber(d.rowsCopied), + formatSizeKb(d.copyThroughput), + formatTime((d.copyDuration ?? 0) * 1000), + loc.ParallelCopyType[d.parallelCopyType] ?? d.parallelCopyType, + d.usedParallelCopies, + formatDateTimeString(d.copyStart), + ]; + }) ?? []; + + // Filter tableData + const filteredData = this._filterTables(data, this._tableFilterInputBox.value); + + await this._progressTable.updateProperty('data', filteredData); + } + + protected async initialize(view: azdata.ModelView): Promise { + try { + this._progressTable = this.view.modelBuilder.table() + .withProps({ + ariaLabel: loc.ACTIVE_BACKUP_FILES, + CSSStyles: { + 'padding-left': '0px', + 'max-width': '1111px' + }, + data: [], + height: '300px', + columns: [ + { + value: TableColumns.tableName, + name: loc.SQLDB_COL_TABLE_NAME, + type: azdata.ColumnType.text, + width: 170, + }, + { + name: loc.STATUS, + value: TableColumns.status, + width: 106, + type: azdata.ColumnType.hyperlink, + icon: IconPathHelper.inProgressMigration, + showText: true, + }, + { + value: TableColumns.dataRead, + name: loc.SQLDB_COL_DATA_READ, + width: 64, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.dataWritten, + name: loc.SQLDB_COL_DATA_WRITTEN, + width: 77, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.rowsRead, + name: loc.SQLDB_COL_ROWS_READ, + width: 68, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.rowsCopied, + name: loc.SQLDB_COL_ROWS_COPIED, + width: 77, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.copyThroughput, + name: loc.SQLDB_COL_COPY_THROUGHPUT, + width: 102, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.copyDuration, + name: loc.SQLDB_COL_COPY_DURATION, + width: 87, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.parallelCopyType, + name: loc.SQLDB_COL_PARRALEL_COPY_TYPE, + width: 104, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.usedParallelCopies, + name: loc.SQLDB_COL_USED_PARALLEL_COPIES, + width: 116, + type: azdata.ColumnType.text, + }, + { + value: TableColumns.copyStart, + name: loc.SQLDB_COL_COPY_START, + width: 140, + type: azdata.ColumnType.text, + }, + ], + }).component(); + + const formItems: azdata.FormComponent[] = [ + { component: this.createMigrationToolbarContainer() }, + { component: await this.migrationInfoGrid() }, + { + component: this.view.modelBuilder.separator() + .withProps({ width: '100%', CSSStyles: { 'padding': '0' } }) + .component() + }, + { component: await this._createStatusBar() }, + { component: await this._createTableFilter() }, + { component: this._progressTable }, + ]; + + this.disposables.push( + this._progressTable.onCellAction!( + async (rowState: azdata.ICellActionEventArgs) => { + const buttonState = rowState; + if (buttonState?.column === 1) { + const tableName = this._progressTable!.data[rowState.row][0] || null; + const tableProgress = this.model.migration.properties.migrationStatusDetails?.listOfCopyProgressDetails?.find( + progress => progress.tableName === tableName); + const errors = tableProgress?.errors || []; + const tableStatus = loc.PipelineRunStatus[tableProgress?.status ?? ''] ?? tableProgress?.status; + const statusMessage = loc.TABLE_MIGRATION_STATUS_LABEL(tableStatus); + const errorMessage = errors.join(EOL); + + this.showDialogMessage( + loc.TABLE_MIGRATION_STATUS_TITLE, + statusMessage, + errorMessage); + } + })); + + const formContainer = this.view.modelBuilder.formContainer() + .withFormItems( + formItems, + { horizontal: false }) + .withProps({ width: '100%', CSSStyles: { margin: '0 0 0 5px', padding: '0 15px 0 15px' } }) + .component(); + + this.content = formContainer; + } catch (e) { + logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e); + } + } + + private _sortTableMigrations(data: CopyProgressDetail[], columnName: string, ascending: boolean): void { + const sortDir = ascending ? -1 : 1; + switch (columnName) { + case TableColumns.tableName: + data.sort((t1, t2) => this.stringCompare(t1.tableName, t2.tableName, sortDir)); + return; + case TableColumns.status: + data.sort((t1, t2) => this.stringCompare(t1.status, t2.status, sortDir)); + return; + case TableColumns.dataRead: + data.sort((t1, t2) => this.numberCompare(t1.dataRead, t2.dataRead, sortDir)); + return; + case TableColumns.dataWritten: + data.sort((t1, t2) => this.numberCompare(t1.dataWritten, t2.dataWritten, sortDir)); + return; + case TableColumns.rowsRead: + data.sort((t1, t2) => this.numberCompare(t1.rowsRead, t2.rowsRead, sortDir)); + return; + case TableColumns.rowsCopied: + data.sort((t1, t2) => this.numberCompare(t1.rowsCopied, t2.rowsCopied, sortDir)); + return; + case TableColumns.copyThroughput: + data.sort((t1, t2) => this.numberCompare(t1.copyThroughput, t2.copyThroughput, sortDir)); + return; + case TableColumns.copyDuration: + data.sort((t1, t2) => this.numberCompare(t1.copyDuration, t2.copyDuration, sortDir)); + return; + case TableColumns.parallelCopyType: + data.sort((t1, t2) => this.stringCompare(t1.parallelCopyType, t2.parallelCopyType, sortDir)); + return; + case TableColumns.usedParallelCopies: + data.sort((t1, t2) => this.numberCompare(t1.usedParallelCopies, t2.usedParallelCopies, sortDir)); + return; + case TableColumns.copyStart: + data.sort((t1, t2) => this.dateCompare(t1.copyStart, t2.copyStart, sortDir)); + return; + } + } + + private _updateSummaryComponent(cardIndex: number, value: number): void { + const stringValue = value.toLocaleString(); + const textComponent = this.summaryTextComponent[cardIndex]; + textComponent.value = stringValue; + textComponent.title = stringValue; + } + + private _filterTables(tables: any[], value: string | undefined): any[] { + const lcValue = value?.toLowerCase() ?? ''; + + return lcValue.length > 0 + ? tables.filter((table: string[]) => + table.some((col: string | { title: string }) => { + return typeof (col) === 'string' + ? col.toLowerCase().includes(lcValue) + : col.title?.toLowerCase().includes(lcValue); + })) + : tables; + } + + private async _createTableFilter(): Promise { + this._tableFilterInputBox = this.view.modelBuilder.inputBox() + .withProps({ + inputType: 'text', + maxLength: 100, + width: 268, + placeHolder: loc.FILTER_SERVER_OBJECTS_PLACEHOLDER, + ariaLabel: loc.FILTER_SERVER_OBJECTS_ARIA_LABEL, + }) + .component(); + + this.disposables.push( + this._tableFilterInputBox.onTextChanged( + async (value) => await this._populateTableData())); + + const sortLabel = this.view.modelBuilder.text() + .withProps({ + value: loc.SORT_LABEL, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '600', + 'margin': '3px 0 0 0', + }, + }).component(); + + this._columnSortDropdown = this.view.modelBuilder.dropDown() + .withProps({ + editable: false, + width: 150, + CSSStyles: { 'margin-left': '5px' }, + value: { name: TableColumns.copyStart, displayName: loc.START_TIME }, + values: [ + { name: TableColumns.tableName, displayName: loc.SQLDB_COL_TABLE_NAME }, + { name: TableColumns.status, displayName: loc.STATUS }, + { name: TableColumns.dataRead, displayName: loc.SQLDB_COL_DATA_READ }, + { name: TableColumns.dataWritten, displayName: loc.SQLDB_COL_DATA_WRITTEN }, + { name: TableColumns.rowsRead, displayName: loc.SQLDB_COL_ROWS_READ }, + { name: TableColumns.rowsCopied, displayName: loc.SQLDB_COL_ROWS_COPIED }, + { name: TableColumns.copyThroughput, displayName: loc.SQLDB_COL_COPY_THROUGHPUT }, + { name: TableColumns.copyDuration, displayName: loc.SQLDB_COL_COPY_DURATION }, + { name: TableColumns.parallelCopyType, displayName: loc.SQLDB_COL_PARRALEL_COPY_TYPE }, + { name: TableColumns.usedParallelCopies, displayName: loc.SQLDB_COL_USED_PARALLEL_COPIES }, + { name: TableColumns.copyStart, displayName: loc.SQLDB_COL_COPY_START }, + ], + }) + .component(); + this.disposables.push( + this._columnSortDropdown.onValueChanged( + async (value) => await this._populateTableData())); + + this._columnSortCheckbox = this.view.modelBuilder.checkBox() + .withProps({ + label: loc.ASCENDING_LABEL, + checked: false, + CSSStyles: { 'margin-left': '15px' }, + }) + .component(); + this.disposables.push( + this._columnSortCheckbox.onChanged( + async (value) => await this._populateTableData())); + + const columnSortContainer = this.view.modelBuilder.flexContainer() + .withItems([sortLabel, this._columnSortDropdown]) + .withProps({ + CSSStyles: { + 'justify-content': 'left', + 'align-items': 'center', + 'padding': '0px', + 'display': 'flex', + 'flex-direction': 'row', + }, + }).component(); + columnSortContainer.addItem(this._columnSortCheckbox, { flex: '0 0 auto' }); + + const flexContainer = this.view.modelBuilder.flexContainer() + .withProps({ + width: '100%', + CSSStyles: { + 'justify-content': 'left', + 'align-items': 'center', + 'padding': '0px', + 'display': 'flex', + 'flex-direction': 'row', + 'flex-flow': 'wrap', + }, + }).component(); + flexContainer.addItem(this._tableFilterInputBox, { flex: '0' }); + flexContainer.addItem(columnSortContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); + + return flexContainer; + } + + private async _createStatusBar(): Promise { + const serverObjectsLabel = this.view.modelBuilder.text() + .withProps({ + value: loc.SERVER_OBJECTS_LABEL, + CSSStyles: { + 'font-weight': '600', + 'font-size': '14px', + 'margin': '0 0 5px 0', + }, + }) + .component(); + + const flexContainer = this.view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + flexWrap: 'wrap', + }) + .component(); + + flexContainer.addItems([ + await this.createInfoCard(loc.SERVER_OBJECTS_ALL_TABLES_LABEL, IconPathHelper.allTables), + await this.createInfoCard(loc.SERVER_OBJECTS_IN_PROGRESS_TABLES_LABEL, IconPathHelper.inProgressMigration), + await this.createInfoCard(loc.SERVER_OBJECTS_SUCCESSFUL_TABLES_LABEL, IconPathHelper.completedMigration), + await this.createInfoCard(loc.SERVER_OBJECTS_FAILED_TABLES_LABEL, IconPathHelper.error), + await this.createInfoCard(loc.SERVER_OBJECTS_CANCELLED_TABLES_LABEL, IconPathHelper.cancel) + ], { flex: '0 0 auto', CSSStyles: { 'width': '168px' } }); + + return this.view.modelBuilder.flexContainer() + .withItems([serverObjectsLabel, flexContainer]) + .withLayout({ flexFlow: 'column' }) + .component(); + } + + protected async migrationInfoGrid(): Promise { + const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => { + container.addItem( + infoField.flexContainer, + { CSSStyles: { width: infoFieldLgWidth } }); + }; + + const flexServer = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, ''); + this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, ''); + this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, ''); + addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer); + addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer); + addInfoFieldToContainer(this._sourceVersionInfoField, flexServer); + + const flexTarget = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, ''); + this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, ''); + this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, ''); + addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget); + addInfoFieldToContainer(this._targetServerInfoField, flexTarget); + addInfoFieldToContainer(this._targetVersionInfoField, flexTarget); + + const flexStatus = this.view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .component(); + this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' '); + this._serverObjectsInfoField = await this.createInfoField(loc.SERVER_OBJECTS_FIELD_LABEL, ''); + addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus); + addInfoFieldToContainer(this._serverObjectsInfoField, flexStatus); + + const flexInfoProps = { + flex: '0', + CSSStyles: { 'flex': '0', 'width': infoFieldLgWidth } + }; + + const flexInfo = this.view.modelBuilder.flexContainer() + .withLayout({ flexWrap: 'wrap' }) + .withProps({ width: '100%' }) + .component(); + flexInfo.addItem(flexServer, flexInfoProps); + flexInfo.addItem(flexTarget, flexInfoProps); + flexInfo.addItem(flexStatus, flexInfoProps); + + return flexInfo; + } +} diff --git a/extensions/sql-migration/src/dashboard/migrationsListTab.ts b/extensions/sql-migration/src/dashboard/migrationsListTab.ts new file mode 100644 index 0000000000..6c5093cf5e --- /dev/null +++ b/extensions/sql-migration/src/dashboard/migrationsListTab.ts @@ -0,0 +1,771 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { IconPathHelper } from '../constants/iconPathHelper'; +import { getCurrentMigrations, getSelectedServiceStatus, MigrationLocalStorage } from '../models/migrationLocalStorage'; +import * as loc from '../constants/strings'; +import { filterMigrations, getMigrationDuration, getMigrationStatusImage, getMigrationStatusWithErrors, getMigrationTime } from '../api/utils'; +import { SqlMigrationServiceDetailsDialog } from '../dialog/sqlMigrationService/sqlMigrationServiceDetailsDialog'; +import { ConfirmCutoverDialog } from '../dialog/migrationCutover/confirmCutoverDialog'; +import { MigrationCutoverDialogModel } from '../dialog/migrationCutover/migrationCutoverDialogModel'; +import { getMigrationTargetType, getMigrationMode, canRetryMigration, getMigrationModeEnum, canCancelMigration, canCutoverMigration, getMigrationStatus } from '../constants/helper'; +import { RetryMigrationDialog } from '../dialog/retryMigration/retryMigrationDialog'; +import { DatabaseMigration, getResourceName } from '../api/azure'; +import { logError, TelemetryViews } from '../telemtery'; +import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog'; +import { AdsMigrationStatus, EmptySettingValue, MenuCommands, TabBase } from './tabBase'; +import { DashboardStatusBar } from './sqlServerDashboard'; +import { MigrationMode } from '../models/stateMachine'; + +export const MigrationsListTabId = 'MigrationsListTab'; + +const TableColumns = { + sourceDatabase: 'sourceDatabase', + sourceServer: 'sourceServer', + status: 'status', + mode: 'mode', + targetType: 'targetType', + targetDatabse: 'targetDatabase', + targetServer: 'TargetServer', + duration: 'duration', + startTime: 'startTime', + finishTime: 'finishTime', +}; + +export class MigrationsListTab extends TabBase { + private _searchBox!: azdata.InputBoxComponent; + private _refresh!: azdata.ButtonComponent; + private _serviceContextButton!: azdata.ButtonComponent; + private _statusDropdown!: azdata.DropDownComponent; + private _columnSortDropdown!: azdata.DropDownComponent; + private _columnSortCheckbox!: azdata.CheckBoxComponent; + private _statusTable!: azdata.TableComponent; + private _refreshLoader!: azdata.LoadingComponent; + private _filteredMigrations: DatabaseMigration[] = []; + private _openMigrationDetails!: (migration: DatabaseMigration) => Promise; + private _migrations: DatabaseMigration[] = []; + + constructor() { + super(); + this.id = MigrationsListTabId; + } + + public async create( + context: vscode.ExtensionContext, + view: azdata.ModelView, + openMigrationDetails: (migration: DatabaseMigration) => Promise, + statusBar: DashboardStatusBar, + ): Promise { + + this.view = view; + this.context = context; + this._openMigrationDetails = openMigrationDetails; + this.statusBar = statusBar; + + await this.initialize(); + + return this; + } + + public onDialogClosed = async (): Promise => + await this.updateServiceContext(this._serviceContextButton); + + public async setMigrationFilter(filter: AdsMigrationStatus): Promise { + if (this._statusDropdown.values && this._statusDropdown.values.length > 0) { + const statusFilter = (this._statusDropdown.values) + .find(value => value.name === filter.toString()); + + this._statusDropdown.value = statusFilter; + } + } + + public async refresh(): Promise { + if (this.isRefreshing) { + return; + } + + this.isRefreshing = true; + this._refresh.enabled = false; + this._refreshLoader.loading = true; + await this.statusBar.clearError(); + + try { + await this._statusTable.updateProperty('data', []); + this._migrations = await getCurrentMigrations(); + await this._populateMigrationTable(); + } catch (e) { + await this.statusBar.showError( + loc.DASHBOARD_REFRESH_MIGRATIONS_TITLE, + loc.DASHBOARD_REFRESH_MIGRATIONS_LABEL, + e.message); + logError(TelemetryViews.MigrationsTab, 'refreshMigrations', e); + } finally { + this._refreshLoader.loading = false; + this._refresh.enabled = true; + this.isRefreshing = false; + } + } + + protected async initialize(): Promise { + this._registerCommands(); + + this.content = this.view.modelBuilder.flexContainer() + .withItems( + [ + this._createToolbar(), + await this._createSearchAndSortContainer(), + this._createStatusTable() + ], + { CSSStyles: { 'width': '100%' } } + ).withLayout({ + width: '100%', + flexFlow: 'column', + }).withProps({ CSSStyles: { 'padding': '0px' } }) + .component(); + } + + private _createToolbar(): azdata.ToolbarContainer { + const toolbar = this.view.modelBuilder.toolbarContainer(); + + this._refresh = this.view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.refresh, + iconHeight: 24, + iconWidth: 24, + height: 24, + label: loc.REFRESH_BUTTON_LABEL, + }).component(); + this.disposables.push( + this._refresh.onDidClick( + async (e) => await this.refresh())); + + this._refreshLoader = this.view.modelBuilder.loadingComponent() + .withProps({ + loading: false, + CSSStyles: { + 'height': '8px', + 'margin-top': '6px' + } + }) + .component(); + + toolbar.addToolbarItems([ + { component: this.createNewMigrationButton(), toolbarSeparatorAfter: true }, + { component: this.createNewSupportRequestButton() }, + { component: this.createFeedbackButton(), toolbarSeparatorAfter: true }, + { component: this._refresh }, + { component: this._refreshLoader }, + ]); + + return toolbar.component(); + } + + private async _createSearchAndSortContainer(): Promise { + const serviceContextLabel = await getSelectedServiceStatus(); + this._serviceContextButton = this.view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.sqlMigrationService, + iconHeight: 22, + iconWidth: 22, + label: serviceContextLabel, + title: serviceContextLabel, + description: loc.MIGRATION_SERVICE_DESCRIPTION, + buttonType: azdata.ButtonType.Informational, + width: 230, + }).component(); + + const onDialogClosed = async (): Promise => + await this.updateServiceContext(this._serviceContextButton); + + this.disposables.push( + this._serviceContextButton.onDidClick( + async () => { + const dialog = new SelectMigrationServiceDialog(onDialogClosed); + await dialog.initialize(); + })); + + this._searchBox = this.view.modelBuilder.inputBox() + .withProps({ + stopEnterPropagation: true, + placeHolder: loc.SEARCH_FOR_MIGRATIONS, + width: '200px', + }).component(); + this.disposables.push( + this._searchBox.onTextChanged( + async (value) => await this._populateMigrationTable())); + + const searchLabel = this.view.modelBuilder.text() + .withProps({ + value: loc.STATUS_LABEL, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '600', + 'margin': '3px 0 0 0', + }, + }).component(); + + this._statusDropdown = this.view.modelBuilder.dropDown() + .withProps({ + ariaLabel: loc.MIGRATION_STATUS_FILTER, + values: this._statusDropdownValues, + width: '150px' + }).component(); + this.disposables.push( + this._statusDropdown.onValueChanged( + async (value) => await this._populateMigrationTable())); + + const searchContainer = this.view.modelBuilder.flexContainer() + .withLayout({ + alignContent: 'center', + alignItems: 'center', + }).withProps({ CSSStyles: { 'margin-left': '10px' } }) + .component(); + searchContainer.addItem(searchLabel, { flex: '0' }); + searchContainer.addItem(this._statusDropdown, { flex: '0', CSSStyles: { 'margin-left': '5px' } }); + + const sortLabel = this.view.modelBuilder.text() + .withProps({ + value: loc.SORT_LABEL, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '600', + 'margin': '3px 0 0 0', + }, + }).component(); + + this._columnSortDropdown = this.view.modelBuilder.dropDown() + .withProps({ + editable: false, + width: 120, + CSSStyles: { 'margin-left': '5px' }, + value: { name: TableColumns.startTime, displayName: loc.START_TIME }, + values: [ + { name: TableColumns.sourceDatabase, displayName: loc.SRC_DATABASE }, + { name: TableColumns.sourceServer, displayName: loc.SRC_SERVER }, + { name: TableColumns.status, displayName: loc.STATUS_COLUMN }, + { name: TableColumns.mode, displayName: loc.MIGRATION_MODE }, + { name: TableColumns.targetType, displayName: loc.AZURE_SQL_TARGET }, + { name: TableColumns.targetDatabse, displayName: loc.TARGET_DATABASE_COLUMN }, + { name: TableColumns.targetServer, displayName: loc.TARGET_SERVER_COLUMN }, + { name: TableColumns.duration, displayName: loc.DURATION }, + { name: TableColumns.startTime, displayName: loc.START_TIME }, + { name: TableColumns.finishTime, displayName: loc.FINISH_TIME }, + ], + }) + .component(); + this.disposables.push( + this._columnSortDropdown.onValueChanged( + async (e) => await this._populateMigrationTable())); + + this._columnSortCheckbox = this.view.modelBuilder.checkBox() + .withProps({ + label: loc.ASCENDING_LABEL, + checked: false, + CSSStyles: { 'margin-left': '15px' }, + }) + .component(); + this.disposables.push( + this._columnSortCheckbox.onChanged( + async (e) => await this._populateMigrationTable())); + + const columnSortContainer = this.view.modelBuilder.flexContainer() + .withItems([sortLabel, this._columnSortDropdown]) + .withProps({ + CSSStyles: { + 'justify-content': 'left', + 'align-items': 'center', + 'padding': '0px', + 'display': 'flex', + 'flex-direction': 'row', + }, + }).component(); + columnSortContainer.addItem(this._columnSortCheckbox, { flex: '0 0 auto' }); + + const flexContainer = this.view.modelBuilder.flexContainer() + .withProps({ + width: '100%', + CSSStyles: { + 'justify-content': 'left', + 'align-items': 'center', + 'padding': '0px', + 'display': 'flex', + 'flex-direction': 'row', + 'flex-flow': 'wrap', + }, + }).component(); + + flexContainer.addItem(this._serviceContextButton, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); + flexContainer.addItem(this._searchBox, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); + flexContainer.addItem(searchContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); + flexContainer.addItem(columnSortContainer, { flex: '0', CSSStyles: { 'margin-left': '10px' } }); + + const container = this.view.modelBuilder.flexContainer() + .withProps({ width: '100%' }) + .component(); + + container.addItem(flexContainer); + return container; + } + + private _registerCommands(): void { + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.Cutover, + async (migrationId: string) => { + try { + await this.statusBar.clearError(); + const migration = this._migrations.find( + migration => migration.id === migrationId); + + if (canRetryMigration(migration)) { + const cutoverDialogModel = new MigrationCutoverDialogModel( + await MigrationLocalStorage.getMigrationServiceContext(), + migration!); + await cutoverDialogModel.fetchStatus(); + const dialog = new ConfirmCutoverDialog(cutoverDialogModel); + await dialog.initialize(); + if (cutoverDialogModel.CutoverError) { + void vscode.window.showErrorMessage(loc.MIGRATION_CUTOVER_ERROR); + logError(TelemetryViews.MigrationsTab, MenuCommands.Cutover, cutoverDialogModel.CutoverError); + } + } else { + await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CUTOVER); + } + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_CUTOVER_ERROR, + loc.MIGRATION_CUTOVER_ERROR, + e.message); + + logError(TelemetryViews.MigrationsTab, MenuCommands.Cutover, e); + } + })); + + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.ViewDatabase, + async (migrationId: string) => { + try { + await this.statusBar.clearError(); + const migration = this._migrations.find(m => m.id === migrationId); + await this._openMigrationDetails(migration!); + } catch (e) { + await this.statusBar.showError( + loc.OPEN_MIGRATION_DETAILS_ERROR, + loc.OPEN_MIGRATION_DETAILS_ERROR, + e.message); + logError(TelemetryViews.MigrationsTab, MenuCommands.ViewDatabase, e); + } + })); + + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.ViewTarget, + async (migrationId: string) => { + try { + const migration = this._migrations.find(migration => migration.id === migrationId); + const url = 'https://portal.azure.com/#resource/' + migration!.properties.scope; + await vscode.env.openExternal(vscode.Uri.parse(url)); + } catch (e) { + await this.statusBar.showError( + loc.OPEN_MIGRATION_TARGET_ERROR, + loc.OPEN_MIGRATION_TARGET_ERROR, + e.message); + logError(TelemetryViews.MigrationsTab, MenuCommands.ViewTarget, e); + } + })); + + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.ViewService, + async (migrationId: string) => { + try { + await this.statusBar.clearError(); + const migration = this._migrations.find(migration => migration.id === migrationId); + const dialog = new SqlMigrationServiceDetailsDialog( + await MigrationLocalStorage.getMigrationServiceContext(), + migration!); + await dialog.initialize(); + } catch (e) { + await this.statusBar.showError( + loc.OPEN_MIGRATION_SERVICE_ERROR, + loc.OPEN_MIGRATION_SERVICE_ERROR, + e.message); + logError(TelemetryViews.MigrationsTab, MenuCommands.ViewService, e); + } + })); + + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.CopyMigration, + async (migrationId: string) => { + await this.statusBar.clearError(); + const migration = this._migrations.find(migration => migration.id === migrationId); + const cutoverDialogModel = new MigrationCutoverDialogModel( + await MigrationLocalStorage.getMigrationServiceContext(), + migration!); + + try { + await cutoverDialogModel.fetchStatus(); + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_STATUS_REFRESH_ERROR, + loc.MIGRATION_STATUS_REFRESH_ERROR, + e.message); + logError(TelemetryViews.MigrationsTab, MenuCommands.CopyMigration, e); + } + + await vscode.env.clipboard.writeText(JSON.stringify(cutoverDialogModel.migration, undefined, 2)); + await vscode.window.showInformationMessage(loc.DETAILS_COPIED); + })); + + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.CancelMigration, + async (migrationId: string) => { + try { + await this.statusBar.clearError(); + const migration = this._migrations.find(migration => migration.id === migrationId); + if (canCancelMigration(migration)) { + void vscode.window.showInformationMessage(loc.CANCEL_MIGRATION_CONFIRMATION, loc.YES, loc.NO).then(async (v) => { + if (v === loc.YES) { + const cutoverDialogModel = new MigrationCutoverDialogModel( + await MigrationLocalStorage.getMigrationServiceContext(), + migration!); + await cutoverDialogModel.fetchStatus(); + await cutoverDialogModel.cancelMigration(); + + if (cutoverDialogModel.CancelMigrationError) { + void vscode.window.showErrorMessage(loc.MIGRATION_CANNOT_CANCEL); + logError(TelemetryViews.MigrationsTab, MenuCommands.CancelMigration, cutoverDialogModel.CancelMigrationError); + } + } + }); + } else { + await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CANCEL); + } + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_CANCELLATION_ERROR, + loc.MIGRATION_CANCELLATION_ERROR, + e.message); + logError(TelemetryViews.MigrationsTab, MenuCommands.CancelMigration, e); + } + })); + + this.disposables.push(vscode.commands.registerCommand( + MenuCommands.RetryMigration, + async (migrationId: string) => { + try { + await this.statusBar.clearError(); + const migration = this._migrations.find(migration => migration.id === migrationId); + if (canRetryMigration(migration)) { + let retryMigrationDialog = new RetryMigrationDialog( + this.context, + await MigrationLocalStorage.getMigrationServiceContext(), + migration!, + async () => await this.onDialogClosed()); + await retryMigrationDialog.openDialog(); + } + else { + await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_RETRY); + } + } catch (e) { + await this.statusBar.showError( + loc.MIGRATION_RETRY_ERROR, + loc.MIGRATION_RETRY_ERROR, + e.message); + logError(TelemetryViews.MigrationsTab, MenuCommands.RetryMigration, e); + } + })); + } + + private _sortMigrations(migrations: DatabaseMigration[], columnName: string, ascending: boolean): void { + const sortDir = ascending ? -1 : 1; + switch (columnName) { + case TableColumns.sourceDatabase: + migrations.sort( + (m1, m2) => this.stringCompare( + m1.properties.sourceDatabaseName, + m2.properties.sourceDatabaseName, + sortDir)); + return; + case TableColumns.sourceServer: + migrations.sort( + (m1, m2) => this.stringCompare( + m1.properties.sourceServerName, + m2.properties.sourceServerName, + sortDir)); + return; + case TableColumns.status: + migrations.sort( + (m1, m2) => this.stringCompare( + getMigrationStatusWithErrors(m1), + getMigrationStatusWithErrors(m2), + sortDir)); + return; + case TableColumns.mode: + migrations.sort( + (m1, m2) => this.stringCompare( + getMigrationMode(m1), + getMigrationMode(m2), + sortDir)); + return; + case TableColumns.targetType: + migrations.sort( + (m1, m2) => this.stringCompare( + getMigrationTargetType(m1), + getMigrationTargetType(m2), + sortDir)); + return; + case TableColumns.targetDatabse: + migrations.sort( + (m1, m2) => this.stringCompare( + getResourceName(m1.id), + getResourceName(m2.id), + sortDir)); + return; + case TableColumns.targetServer: + migrations.sort( + (m1, m2) => this.stringCompare( + getResourceName(m1.properties.scope), + getResourceName(m2.properties.scope), + sortDir)); + return; + case TableColumns.duration: + migrations.sort((m1, m2) => { + if (!m1.properties.startedOn) { + return sortDir; + } else if (!m2.properties.startedOn) { + return -sortDir; + } + const m1_startedOn = new Date(m1.properties.startedOn); + const m2_startedOn = new Date(m2.properties.startedOn); + const m1_endedOn = new Date(m1.properties.endedOn ?? Date.now()); + const m2_endedOn = new Date(m2.properties.endedOn ?? Date.now()); + const m1_duration = m1_endedOn.getTime() - m1_startedOn.getTime(); + const m2_duration = m2_endedOn.getTime() - m2_startedOn.getTime(); + return m1_duration > m2_duration ? -sortDir : sortDir; + }); + return; + case TableColumns.startTime: + migrations.sort( + (m1, m2) => this.dateCompare( + m1.properties.startedOn, + m2.properties.startedOn, + sortDir)); + return; + case TableColumns.finishTime: + migrations.sort( + (m1, m2) => this.dateCompare( + m1.properties.endedOn, + m2.properties.endedOn, + sortDir)); + return; + } + } + + private async _populateMigrationTable(): Promise { + try { + this._filteredMigrations = filterMigrations( + this._migrations, + (this._statusDropdown.value).name, + this._searchBox.value!); + + this._sortMigrations( + this._filteredMigrations, + (this._columnSortDropdown.value).name, + this._columnSortCheckbox.checked === true); + + const data: any[] = this._filteredMigrations.map((migration, index) => { + return [ + { + icon: IconPathHelper.sqlDatabaseLogo, + title: migration.properties.sourceDatabaseName ?? EmptySettingValue, + }, // sourceDatabase + migration.properties.sourceServerName ?? EmptySettingValue, // sourceServer + { + icon: getMigrationStatusImage(migration), + title: getMigrationStatusWithErrors(migration), + }, // statue + getMigrationMode(migration), // mode + getMigrationTargetType(migration), // targetType + getResourceName(migration.id), // targetDatabase + getResourceName(migration.properties.scope), // targetServer + getMigrationDuration( + migration.properties.startedOn, + migration.properties.endedOn), // duration + getMigrationTime(migration.properties.startedOn), // startTime + getMigrationTime(migration.properties.endedOn), // finishTime + { + title: '', + context: migration.id, + commands: this._getMenuCommands(migration), // context menu + }, + ]; + }); + + await this._statusTable.updateProperty('data', data); + } catch (e) { + await this.statusBar.showError( + loc.LOAD_MIGRATION_LIST_ERROR, + loc.LOAD_MIGRATION_LIST_ERROR, + e.message); + logError(TelemetryViews.MigrationStatusDialog, 'Error populating migrations list page', e); + } + } + + private _createStatusTable(): azdata.TableComponent { + const headerCssStyles = undefined; + const rowCssStyles = undefined; + + this._statusTable = this.view.modelBuilder.table() + .withProps({ + ariaLabel: loc.MIGRATION_STATUS, + CSSStyles: { 'margin-left': '10px' }, + data: [], + forceFitColumns: azdata.ColumnSizingMode.AutoFit, + height: '500px', + columns: [ + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.SRC_DATABASE, + value: 'sourceDatabase', + width: 190, + type: azdata.ColumnType.hyperlink, + showText: true, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.SRC_SERVER, + value: 'sourceServer', + width: 190, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.STATUS_COLUMN, + value: 'status', + width: 120, + type: azdata.ColumnType.hyperlink, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.MIGRATION_MODE, + value: 'mode', + width: 55, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.AZURE_SQL_TARGET, + value: 'targetType', + width: 120, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.TARGET_DATABASE_COLUMN, + value: 'targetDatabase', + width: 125, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.TARGET_SERVER_COLUMN, + value: 'targetServer', + width: 125, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.DURATION, + value: 'duration', + width: 55, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.START_TIME, + value: 'startTime', + width: 115, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: loc.FINISH_TIME, + value: 'finishTime', + width: 115, + type: azdata.ColumnType.text, + }, + { + cssClass: rowCssStyles, + headerCssClass: headerCssStyles, + name: '', + value: 'contextMenu', + width: 25, + type: azdata.ColumnType.contextMenu, + } + ] + }).component(); + + this.disposables.push(this._statusTable.onCellAction!(async (rowState: azdata.ICellActionEventArgs) => { + const buttonState = rowState; + const migration = this._filteredMigrations[rowState.row]; + switch (buttonState?.column) { + case 2: + const status = getMigrationStatus(migration); + const statusMessage = loc.DATABASE_MIGRATION_STATUS_LABEL(status); + const errors = this.getMigrationErrors(migration!); + + this.showDialogMessage( + loc.DATABASE_MIGRATION_STATUS_TITLE, + statusMessage, + errors); + break; + case 0: + await this._openMigrationDetails(migration); + break; + } + })); + + return this._statusTable; + } + + private _getMenuCommands(migration: DatabaseMigration): string[] { + const menuCommands: string[] = []; + + if (getMigrationModeEnum(migration) === MigrationMode.ONLINE && + canCutoverMigration(migration)) { + menuCommands.push(MenuCommands.Cutover); + } + + menuCommands.push(...[ + MenuCommands.ViewDatabase, + MenuCommands.ViewTarget, + MenuCommands.ViewService, + MenuCommands.CopyMigration]); + + if (canCancelMigration(migration)) { + menuCommands.push(MenuCommands.CancelMigration); + } + + return menuCommands; + } + + private _statusDropdownValues: azdata.CategoryValue[] = [ + { displayName: loc.STATUS_ALL, name: AdsMigrationStatus.ALL }, + { displayName: loc.STATUS_ONGOING, name: AdsMigrationStatus.ONGOING }, + { displayName: loc.STATUS_COMPLETING, name: AdsMigrationStatus.COMPLETING }, + { displayName: loc.STATUS_SUCCEEDED, name: AdsMigrationStatus.SUCCEEDED }, + { displayName: loc.STATUS_FAILED, name: AdsMigrationStatus.FAILED } + ]; +} diff --git a/extensions/sql-migration/src/dashboard/migrationsTab.ts b/extensions/sql-migration/src/dashboard/migrationsTab.ts new file mode 100644 index 0000000000..b56ef3712c --- /dev/null +++ b/extensions/sql-migration/src/dashboard/migrationsTab.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as loc from '../constants/strings'; +import { AdsMigrationStatus, TabBase } from './tabBase'; +import { MigrationsListTab, MigrationsListTabId } from './migrationsListTab'; +import { DatabaseMigration } from '../api/azure'; +import { MigrationLocalStorage } from '../models/migrationLocalStorage'; +import { FileStorageType } from '../models/stateMachine'; +import { MigrationDetailsTabBase } from './migrationDetailsTabBase'; +import { MigrationDetailsFileShareTab } from './migrationDetailsFileShareTab'; +import { MigrationDetailsBlobContainerTab } from './migrationDetailsBlobContainerTab'; +import { MigrationDetailsTableTab } from './migrationDetailsTableTab'; +import { DashboardStatusBar } from './sqlServerDashboard'; + +export const MigrationsTabId = 'MigrationsTab'; + +export class MigrationsTab extends TabBase { + private _tab!: azdata.DivContainer; + private _migrationsListTab!: MigrationsListTab; + private _migrationDetailsTab!: MigrationDetailsTabBase; + private _migrationDetailsFileShareTab!: MigrationDetailsTabBase; + private _migrationDetailsBlobTab!: MigrationDetailsTabBase; + private _migrationDetailsTableTab!: MigrationDetailsTabBase; + private _selectedTabId: string | undefined = undefined; + + constructor() { + super(); + this.title = loc.DESKTOP_MIGRATIONS_TAB_TITLE; + this.id = MigrationsTabId; + } + + public onDialogClosed = async (): Promise => + await this._migrationsListTab.onDialogClosed(); + + public async create( + context: vscode.ExtensionContext, + view: azdata.ModelView, + statusBar: DashboardStatusBar): Promise { + + this.context = context; + this.view = view; + this.statusBar = statusBar; + + await this.initialize(view); + await this._openTab(this._migrationsListTab); + + return this; + } + + public async refresh(): Promise { + switch (this._selectedTabId) { + case undefined: + case MigrationsListTabId: + return await this._migrationsListTab?.refresh(); + default: + return await this._migrationDetailsTab?.refresh(); + } + } + + protected async initialize(view: azdata.ModelView): Promise { + this._tab = this.view.modelBuilder.divContainer() + .withLayout({ height: '100%' }) + .withProps({ + CSSStyles: { + 'margin': '0px', + 'padding': '0px', + 'width': '100%' + } + }) + .component(); + + this._migrationsListTab = await new MigrationsListTab().create( + this.context, + this.view, + async (migration) => await this._openMigrationDetails(migration), + this.statusBar); + this.disposables.push(this._migrationsListTab); + + this._migrationDetailsBlobTab = await new MigrationDetailsBlobContainerTab().create( + this.context, + this.view, + async () => await this._openMigrationsListTab(), + this.statusBar); + this.disposables.push(this._migrationDetailsBlobTab); + + this._migrationDetailsFileShareTab = await new MigrationDetailsFileShareTab().create( + this.context, + this.view, + async () => await this._openMigrationsListTab(), + this.statusBar); + this.disposables.push(this._migrationDetailsFileShareTab); + + this._migrationDetailsTableTab = await new MigrationDetailsTableTab().create( + this.context, + this.view, + async () => await this._openMigrationsListTab(), + this.statusBar); + this.disposables.push(this._migrationDetailsFileShareTab); + + this.content = this._tab; + } + + public async setMigrationFilter(filter: AdsMigrationStatus): Promise { + await this._migrationsListTab?.setMigrationFilter(filter); + await this._openTab(this._migrationsListTab); + await this._migrationsListTab?.setMigrationFilter(filter); + } + + private async _openMigrationDetails(migration: DatabaseMigration): Promise { + switch (migration.properties.backupConfiguration?.sourceLocation?.fileStorageType) { + case FileStorageType.AzureBlob: + this._migrationDetailsTab = this._migrationDetailsBlobTab; + break; + case FileStorageType.FileShare: + this._migrationDetailsTab = this._migrationDetailsFileShareTab; + break; + case FileStorageType.None: + this._migrationDetailsTab = this._migrationDetailsTableTab; + break; + } + + await this._migrationDetailsTab.setMigrationContext( + await MigrationLocalStorage.getMigrationServiceContext(), + migration); + + await this._openTab(this._migrationDetailsTab); + } + + private async _openMigrationsListTab(): Promise { + await this.statusBar.clearError(); + await this._openTab(this._migrationsListTab); + } + + private async _openTab(tab: azdata.Tab): Promise { + if (tab.id === this._selectedTabId) { + return; + } + + this._tab.clearItems(); + this._tab.addItem(tab.content); + this._selectedTabId = tab.id; + } +} diff --git a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts index 20507936f0..304a2e2a1d 100644 --- a/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts +++ b/extensions/sql-migration/src/dashboard/sqlServerDashboard.ts @@ -5,793 +5,193 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; -import { logError, TelemetryViews } from '../telemtery'; import * as loc from '../constants/strings'; -import { IconPath, IconPathHelper } from '../constants/iconPathHelper'; -import { MigrationStatusDialog } from '../dialog/migrationStatus/migrationStatusDialog'; -import { AdsMigrationStatus } from '../dialog/migrationStatus/migrationStatusDialogModel'; -import { filterMigrations } from '../api/utils'; -import * as styles from '../constants/styles'; -import * as nls from 'vscode-nls'; -import { SelectMigrationServiceDialog } from '../dialog/selectMigrationService/selectMigrationServiceDialog'; -import { DatabaseMigration } from '../api/azure'; -import { getCurrentMigrations, getSelectedServiceStatus, isServiceContextValid, MigrationLocalStorage } from '../models/migrationLocalStorage'; -const localize = nls.loadMessageBundle(); +import { DashboardTab } from './dashboardTab'; +import { MigrationsTab, MigrationsTabId } from './migrationsTab'; +import { AdsMigrationStatus } from './tabBase'; -interface IActionMetadata { - title?: string, - description?: string, - link?: string, - iconPath?: azdata.ThemedIconPath, - command?: string; +export interface DashboardStatusBar { + showError: (errorTitle: string, errorLable: string, errorDescription: string) => Promise; + clearError: () => Promise; + errorTitle: string; + errorLabel: string; + errorDescription: string; } -const maxWidth = 800; -const BUTTON_CSS = { - 'font-size': '13px', - 'line-height': '18px', - 'margin': '4px 0', - 'text-align': 'left', -}; - -interface StatusCard { - container: azdata.DivContainer; - count: azdata.TextComponent, - textContainer?: azdata.FlexContainer, - warningContainer?: azdata.FlexContainer, - warningText?: azdata.TextComponent, -} - -export class DashboardWidget { +export class DashboardWidget implements DashboardStatusBar { private _context: vscode.ExtensionContext; - private _migrationStatusCardsContainer!: azdata.FlexContainer; - private _migrationStatusCardLoadingContainer!: azdata.LoadingComponent; private _view!: azdata.ModelView; - private _inProgressMigrationButton!: StatusCard; - private _inProgressWarningMigrationButton!: StatusCard; - private _allMigrationButton!: StatusCard; - private _successfulMigrationButton!: StatusCard; - private _failedMigrationButton!: StatusCard; - private _completingMigrationButton!: StatusCard; - private _selectServiceText!: azdata.TextComponent; - private _serviceContextButton!: azdata.ButtonComponent; - private _refreshButton!: azdata.ButtonComponent; - + private _tabs!: azdata.TabbedPanelComponent; + private _statusInfoBox!: azdata.InfoBoxComponent; + private _dashboardTab!: DashboardTab; + private _migrationsTab!: MigrationsTab; private _disposables: vscode.Disposable[] = []; - private isRefreshing: boolean = false; - - public onDialogClosed = async (): Promise => { - const label = await getSelectedServiceStatus(); - this._serviceContextButton.label = label; - this._serviceContextButton.title = label; - await this.refreshMigrations(); - }; constructor(context: vscode.ExtensionContext) { this._context = context; } + public errorTitle: string = ''; + public errorLabel: string = ''; + public errorDescription: string = ''; + + public async showError(errorTitle: string, errorLabel: string, errorDescription: string): Promise { + this.errorTitle = errorTitle; + this.errorLabel = errorLabel; + this.errorDescription = errorDescription; + this._statusInfoBox.style = 'error'; + this._statusInfoBox.text = errorTitle; + await this._updateStatusDisplay(this._statusInfoBox, true); + } + + public async clearError(): Promise { + await this._updateStatusDisplay(this._statusInfoBox, false); + this.errorTitle = ''; + this.errorLabel = ''; + this.errorDescription = ''; + this._statusInfoBox.style = 'success'; + this._statusInfoBox.text = ''; + } + public register(): void { - azdata.ui.registerModelViewProvider('migration.dashboard', async (view) => { + azdata.ui.registerModelViewProvider('migration-dashboard', async (view) => { this._view = view; - - const container = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column', - width: '100%', - height: '100%' - }).component(); - - const header = this.createHeader(view); - // Files need to have the vscode-file scheme to be loaded by ADS - const watermarkUri = vscode.Uri - .file(IconPathHelper.migrationDashboardHeaderBackground.light) - .with({ scheme: 'vscode-file' }); - - container.addItem(header, { - CSSStyles: { - 'background-image': ` - url(${watermarkUri}), - linear-gradient(0deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0) 100%) - `, - 'background-repeat': 'no-repeat', - 'background-position': '91.06% 100%', - 'margin-bottom': '20px' - } - }); - - const tasksContainer = await this.createTasks(view); - header.addItem(tasksContainer, { - CSSStyles: { - 'width': `${maxWidth}px`, - 'margin': '24px' - } - }); - container.addItem(await this.createFooter(view), { - CSSStyles: { - 'margin': '0 24px' - } - }); this._disposables.push( this._view.onClosed(e => { this._disposables.forEach( d => { try { d.dispose(); } catch { } }); })); - await view.initializeModel(container); - await this.refreshMigrations(); + const openMigrationFcn = async (filter: AdsMigrationStatus): Promise => { + this._tabs.selectTab(MigrationsTabId); + await this._migrationsTab.setMigrationFilter(filter); + }; + + this._dashboardTab = await new DashboardTab().create( + view, + async (filter: AdsMigrationStatus) => await openMigrationFcn(filter), + this); + this._disposables.push(this._dashboardTab); + + this._migrationsTab = await new MigrationsTab().create( + this._context, + view, + this); + this._disposables.push(this._migrationsTab); + + this._tabs = view.modelBuilder.tabbedPanel() + .withTabs([this._dashboardTab, this._migrationsTab]) + .withLayout({ alwaysShowTabs: true, orientation: azdata.TabOrientation.Horizontal }) + .withProps({ + CSSStyles: { + 'margin': '0px', + 'padding': '0px', + 'width': '100%' + } + }) + .component(); + + this._disposables.push( + this._tabs.onTabChanged( + async id => { + await this.clearError(); + await this.onDialogClosed(); + })); + + this._statusInfoBox = view.modelBuilder.infoBox() + .withProps({ + style: 'error', + text: '', + announceText: true, + isClickable: true, + display: 'none', + CSSStyles: { 'font-size': '14px' }, + }).component(); + + this._disposables.push( + this._statusInfoBox.onDidClick( + async e => await this.openErrorDialog())); + + const flexContainer = view.modelBuilder.flexContainer() + .withLayout({ flexFlow: 'column' }) + .withItems([this._statusInfoBox, this._tabs]) + .component(); + await view.initializeModel(flexContainer); + + await this.refresh(); }); } - private createHeader(view: azdata.ModelView): azdata.FlexContainer { - const header = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column', - width: maxWidth, - }).component(); - const titleComponent = view.modelBuilder.text().withProps({ - value: loc.DASHBOARD_TITLE, - width: '750px', - CSSStyles: { - ...styles.DASHBOARD_TITLE_CSS - } - }).component(); - - const descriptionComponent = view.modelBuilder.text().withProps({ - value: loc.DASHBOARD_DESCRIPTION, - CSSStyles: { - ...styles.NOTE_CSS - } - }).component(); - header.addItems([titleComponent, descriptionComponent], { - CSSStyles: { - 'width': `${maxWidth}px`, - 'padding-left': '24px' - } - }); - return header; + public async refresh(): Promise { + void this._migrationsTab.refresh(); + await this._dashboardTab.refresh(); } - private async createTasks(view: azdata.ModelView): Promise { - const tasksContainer = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'row', - width: '100%', - }).component(); - - const migrateButtonMetadata: IActionMetadata = { - title: loc.DASHBOARD_MIGRATE_TASK_BUTTON_TITLE, - description: loc.DASHBOARD_MIGRATE_TASK_BUTTON_DESCRIPTION, - iconPath: IconPathHelper.sqlMigrationLogo, - command: 'sqlmigration.start' - }; - - const preRequisiteListTitle = view.modelBuilder.text().withProps({ - value: loc.PRE_REQ_TITLE, - CSSStyles: { - ...styles.BODY_CSS, - 'margin': '0px', - } - }).component(); - - const migrateButton = this.createTaskButton(view, migrateButtonMetadata); - - const preRequisiteListElement = view.modelBuilder.text().withProps({ - value: [ - loc.PRE_REQ_1, - loc.PRE_REQ_2, - loc.PRE_REQ_3 - ], - CSSStyles: { - ...styles.SMALL_NOTE_CSS, - 'padding-left': '12px', - 'margin': '-0.5em 0px', - } - }).component(); - - const preRequisiteLearnMoreLink = view.modelBuilder.hyperlink().withProps({ - label: loc.LEARN_MORE, - ariaLabel: loc.LEARN_MORE_ABOUT_PRE_REQS, - url: 'https://aka.ms/azuresqlmigrationextension', - }).component(); - - const preReqContainer = view.modelBuilder.flexContainer().withItems([ - preRequisiteListTitle, - preRequisiteListElement, - preRequisiteLearnMoreLink - ]).withLayout({ - flexFlow: 'column' - }).component(); - - tasksContainer.addItem(migrateButton, {}); - tasksContainer.addItems([preReqContainer], { - CSSStyles: { - 'margin-left': '20px' - } - }); - return tasksContainer; + public async onDialogClosed(): Promise { + await this._dashboardTab.onDialogClosed(); + await this._migrationsTab.onDialogClosed(); } - private createTaskButton(view: azdata.ModelView, taskMetaData: IActionMetadata): azdata.Component { - const maxHeight: number = 84; - const maxWidth: number = 236; - const buttonContainer = view.modelBuilder.button().withProps({ - buttonType: azdata.ButtonType.Informational, - description: taskMetaData.description, - height: maxHeight, - iconHeight: 32, - iconPath: taskMetaData.iconPath, - iconWidth: 32, - label: taskMetaData.title, - title: taskMetaData.title, - width: maxWidth, - CSSStyles: { - 'border': '1px solid', - 'display': 'flex', - 'flex-direction': 'column', - 'justify-content': 'flex-start', - 'border-radius': '4px', - 'transition': 'all .5s ease', - } - }).component(); - this._disposables.push( - buttonContainer.onDidClick(async () => { - if (taskMetaData.command) { - await vscode.commands.executeCommand(taskMetaData.command); - } - })); - return view.modelBuilder.divContainer().withItems([buttonContainer]).component(); - } + private _errorDialogIsOpen: boolean = false; - public async refreshMigrations(): Promise { - if (this.isRefreshing) { + protected async openErrorDialog(): Promise { + if (this._errorDialogIsOpen) { return; } - this.isRefreshing = true; - this._migrationStatusCardLoadingContainer.loading = true; - let migrations: DatabaseMigration[] = []; try { - migrations = await getCurrentMigrations(); - } catch (e) { - logError(TelemetryViews.SqlServerDashboard, 'RefreshgMigrationFailed', e); - void vscode.window.showErrorMessage(loc.DASHBOARD_REFRESH_MIGRATIONS(e.message)); - } + const tab = azdata.window.createTab(this.errorTitle); + tab.registerContent(async (view) => { + const flex = view.modelBuilder.flexContainer() + .withItems([ + view.modelBuilder.text() + .withProps({ value: this.errorLabel, CSSStyles: { 'margin': '0px 0px 5px 5px' } }) + .component(), + view.modelBuilder.inputBox() + .withProps({ + value: this.errorDescription, + readOnly: true, + multiline: true, + inputType: 'text', + rows: 20, + CSSStyles: { 'overflow': 'hidden auto', 'margin': '0px 0px 0px 5px' }, + }) + .component() + ]) + .withLayout({ + flexFlow: 'column', + width: 420, + }) + .withProps({ CSSStyles: { 'margin': '0 10px 0 10px' } }) + .component(); - const inProgressMigrations = filterMigrations(migrations, AdsMigrationStatus.ONGOING); - let warningCount = 0; - for (let i = 0; i < inProgressMigrations.length; i++) { - if (inProgressMigrations[i].properties.migrationFailureError?.message || - inProgressMigrations[i].properties.migrationStatusDetails?.fileUploadBlockingErrors || - inProgressMigrations[i].properties.migrationStatusDetails?.restoreBlockingReason) { - warningCount += 1; - } - } - if (warningCount > 0) { - this._inProgressWarningMigrationButton.warningText!.value = loc.MIGRATION_INPROGRESS_WARNING(warningCount); - this._inProgressMigrationButton.container.display = 'none'; - this._inProgressWarningMigrationButton.container.display = ''; - } else { - this._inProgressMigrationButton.container.display = ''; - this._inProgressWarningMigrationButton.container.display = 'none'; - } + await view.initializeModel(flex); + }); - this._inProgressMigrationButton.count.value = inProgressMigrations.length.toString(); - this._inProgressWarningMigrationButton.count.value = inProgressMigrations.length.toString(); - - this._updateStatusCard(migrations, this._successfulMigrationButton, AdsMigrationStatus.SUCCEEDED, true); - this._updateStatusCard(migrations, this._failedMigrationButton, AdsMigrationStatus.FAILED); - this._updateStatusCard(migrations, this._completingMigrationButton, AdsMigrationStatus.COMPLETING); - this._updateStatusCard(migrations, this._allMigrationButton, AdsMigrationStatus.ALL, true); - - await this._updateSummaryStatus(); - this.isRefreshing = false; - this._migrationStatusCardLoadingContainer.loading = false; - } - - private _updateStatusCard( - migrations: DatabaseMigration[], - card: StatusCard, - status: AdsMigrationStatus, - show?: boolean): void { - const list = filterMigrations(migrations, status); - const count = list?.length || 0; - card.container.display = count > 0 || show ? '' : 'none'; - card.count.value = count.toString(); - } - private createStatusCard( - cardIconPath: IconPath, - cardTitle: string, - hasSubtext: boolean = false - ): StatusCard { - const buttonWidth = '400px'; - const buttonHeight = hasSubtext ? '70px' : '50px'; - const statusCard = this._view.modelBuilder.flexContainer() - .withProps({ - CSSStyles: { - 'width': buttonWidth, - 'height': buttonHeight, - 'align-items': 'center', - } - }).component(); - - const statusIcon = this._view.modelBuilder.image() - .withProps({ - iconPath: cardIconPath!.light, - iconHeight: 24, - iconWidth: 24, - height: 32, - CSSStyles: { 'margin': '0 8px' } - }).component(); - - const textContainer = this._view.modelBuilder.flexContainer() - .withLayout({ flexFlow: 'column' }) - .component(); - - const cardTitleText = this._view.modelBuilder.text() - .withProps({ value: cardTitle }) - .withProps({ - CSSStyles: { - ...styles.SECTION_HEADER_CSS, - 'width': '240px', - } - }).component(); - textContainer.addItem(cardTitleText); - - const cardCount = this._view.modelBuilder.text().withProps({ - value: '0', - CSSStyles: { - ...styles.BIG_NUMBER_CSS, - 'margin': '0 0 0 8px', - 'text-align': 'center', - } - }).component(); - - let warningContainer; - let warningText; - if (hasSubtext) { - const warningIcon = this._view.modelBuilder.image() - .withProps({ - iconPath: IconPathHelper.warning, - iconWidth: 12, - iconHeight: 12, - width: 12, - height: 18, - }).component(); - - const warningDescription = ''; - warningText = this._view.modelBuilder.text().withProps({ value: warningDescription }) - .withProps({ - CSSStyles: { - ...styles.BODY_CSS, - 'padding-left': '8px', + const dialog = azdata.window.createModelViewDialog( + this.errorTitle, + 'errorDialog', + 450, + 'flyout'); + dialog.content = [tab]; + dialog.okButton.label = loc.ERROR_DIALOG_CLEAR_BUTTON_LABEL; + dialog.okButton.focused = true; + dialog.cancelButton.label = loc.CLOSE; + this._disposables.push( + dialog.onClosed(async e => { + if (e === 'ok') { + await this.clearError(); } - }).component(); + this._errorDialogIsOpen = false; + })); - warningContainer = this._view.modelBuilder.flexContainer() - .withItems( - [warningIcon, warningText], - { flex: '0 0 auto' }) - .withProps({ - CSSStyles: { 'align-items': 'center' } - }).component(); - - textContainer.addItem(warningContainer); + azdata.window.openDialog(dialog); + } catch (error) { + this._errorDialogIsOpen = false; } - - statusCard.addItems([ - statusIcon, - textContainer, - cardCount, - ]); - - const compositeButton = this._view.modelBuilder.divContainer() - .withItems([statusCard]) - .withProps({ - ariaRole: 'button', - ariaLabel: loc.SHOW_STATUS, - clickable: true, - CSSStyles: { - 'height': buttonHeight, - 'margin-bottom': '16px', - 'border': '1px solid', - 'display': 'flex', - 'flex-direction': 'column', - 'justify-content': 'flex-start', - 'border-radius': '4px', - 'transition': 'all .5s ease', - } - }).component(); - return { - container: compositeButton, - count: cardCount, - textContainer: textContainer, - warningContainer: warningContainer, - warningText: warningText - }; } - private async createFooter(view: azdata.ModelView): Promise { - const footerContainer = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'row', - width: maxWidth, - justifyContent: 'flex-start' - }).component(); - const statusContainer = await this.createMigrationStatusContainer(view); - const videoLinksContainer = this.createVideoLinks(view); - footerContainer.addItem(statusContainer); - footerContainer.addItem(videoLinksContainer, { - CSSStyles: { - 'padding-left': '8px', - } - }); - - return footerContainer; - } - - private async createMigrationStatusContainer(view: azdata.ModelView): Promise { - const statusContainer = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column', - width: '400px', - height: '385px', - justifyContent: 'flex-start', - }).withProps({ - CSSStyles: { - 'border': '1px solid rgba(0, 0, 0, 0.1)', - 'padding': '10px', - } - }).component(); - - const statusContainerTitle = view.modelBuilder.text() - .withProps({ - value: loc.DATABASE_MIGRATION_STATUS, - width: '100%', - CSSStyles: { ...styles.SECTION_HEADER_CSS } - }).component(); - - this._refreshButton = view.modelBuilder.button() - .withProps({ - label: loc.REFRESH, - iconPath: IconPathHelper.refresh, - iconHeight: 16, - iconWidth: 16, - width: 70, - CSSStyles: { 'float': 'right' } - }).component(); - - const statusHeadingContainer = view.modelBuilder.flexContainer() - .withItems([ - statusContainerTitle, - this._refreshButton, - ]).withLayout({ - alignContent: 'center', - alignItems: 'center', - flexFlow: 'row', - }).component(); - - this._disposables.push( - this._refreshButton.onDidClick(async (e) => { - this._refreshButton.enabled = false; - await this.refreshMigrations(); - this._refreshButton.enabled = true; - })); - - const buttonContainer = view.modelBuilder.flexContainer() - .withProps({ - CSSStyles: { - 'justify-content': 'left', - 'align-iems': 'center', - }, - }) - .component(); - - buttonContainer.addItem( - await this.createServiceSelector(this._view)); - - this._selectServiceText = view.modelBuilder.text() - .withProps({ - value: loc.SELECT_SERVICE_MESSAGE, - CSSStyles: { - 'font-size': '12px', - 'margin': '10px', - 'font-weight': '350', - 'text-align': 'center', - 'display': 'none' - } - }).component(); - - const header = view.modelBuilder.flexContainer() - .withItems([statusHeadingContainer, buttonContainer]) - .withLayout({ flexFlow: 'column', }) - .component(); - - this._migrationStatusCardsContainer = view.modelBuilder.flexContainer() - .withLayout({ - flexFlow: 'column', - height: '272px', - }) - .withProps({ CSSStyles: { 'overflow': 'hidden auto' } }) - .component(); - - await this._updateSummaryStatus(); - - // in progress - this._inProgressMigrationButton = this.createStatusCard( - IconPathHelper.inProgressMigration, - loc.MIGRATION_IN_PROGRESS); - this._disposables.push( - this._inProgressMigrationButton.container.onDidClick(async (e) => { - const dialog = new MigrationStatusDialog( - this._context, - AdsMigrationStatus.ONGOING, - this.onDialogClosed); - await dialog.initialize(); - })); - - this._migrationStatusCardsContainer.addItem( - this._inProgressMigrationButton.container, - { flex: '0 0 auto' }); - - // in progress warning - this._inProgressWarningMigrationButton = this.createStatusCard( - IconPathHelper.inProgressMigration, - loc.MIGRATION_IN_PROGRESS, - true); - this._disposables.push( - this._inProgressWarningMigrationButton.container.onDidClick(async (e) => { - const dialog = new MigrationStatusDialog( - this._context, - AdsMigrationStatus.ONGOING, - this.onDialogClosed); - await dialog.initialize(); - })); - - this._migrationStatusCardsContainer.addItem( - this._inProgressWarningMigrationButton.container, - { flex: '0 0 auto' }); - - // successful - this._successfulMigrationButton = this.createStatusCard( - IconPathHelper.completedMigration, - loc.MIGRATION_COMPLETED); - this._disposables.push( - this._successfulMigrationButton.container.onDidClick(async (e) => { - const dialog = new MigrationStatusDialog( - this._context, - AdsMigrationStatus.SUCCEEDED, - this.onDialogClosed); - await dialog.initialize(); - })); - this._migrationStatusCardsContainer.addItem( - this._successfulMigrationButton.container, - { flex: '0 0 auto' }); - - // completing - this._completingMigrationButton = this.createStatusCard( - IconPathHelper.completingCutover, - loc.MIGRATION_CUTOVER_CARD); - this._disposables.push( - this._completingMigrationButton.container.onDidClick(async (e) => { - const dialog = new MigrationStatusDialog( - this._context, - AdsMigrationStatus.COMPLETING, - this.onDialogClosed); - await dialog.initialize(); - })); - this._migrationStatusCardsContainer.addItem( - this._completingMigrationButton.container, - { flex: '0 0 auto' }); - - // failed - this._failedMigrationButton = this.createStatusCard( - IconPathHelper.error, - loc.MIGRATION_FAILED); - this._disposables.push( - this._failedMigrationButton.container.onDidClick(async (e) => { - const dialog = new MigrationStatusDialog( - this._context, - AdsMigrationStatus.FAILED, - this.onDialogClosed); - await dialog.initialize(); - })); - this._migrationStatusCardsContainer.addItem( - this._failedMigrationButton.container, - { flex: '0 0 auto' }); - - // all migrations - this._allMigrationButton = this.createStatusCard( - IconPathHelper.view, - loc.VIEW_ALL); - this._disposables.push( - this._allMigrationButton.container.onDidClick(async (e) => { - const dialog = new MigrationStatusDialog( - this._context, - AdsMigrationStatus.ALL, - this.onDialogClosed); - await dialog.initialize(); - })); - this._migrationStatusCardsContainer.addItem( - this._allMigrationButton.container, - { flex: '0 0 auto' }); - - this._migrationStatusCardLoadingContainer = view.modelBuilder.loadingComponent() - .withItem(this._migrationStatusCardsContainer) - .component(); - statusContainer.addItem(header, { CSSStyles: { 'margin-bottom': '10px' } }); - statusContainer.addItem(this._selectServiceText, {}); - statusContainer.addItem(this._migrationStatusCardLoadingContainer, {}); - return statusContainer; - } - - private async _updateSummaryStatus(): Promise { - const serviceContext = await MigrationLocalStorage.getMigrationServiceContext(); - const isContextValid = isServiceContextValid(serviceContext); - await this._selectServiceText.updateCssStyles({ 'display': isContextValid ? 'none' : 'block' }); - await this._migrationStatusCardsContainer.updateCssStyles({ 'visibility': isContextValid ? 'visible' : 'hidden' }); - this._refreshButton.enabled = isContextValid; - } - - private async createServiceSelector(view: azdata.ModelView): Promise { - const serviceContextLabel = await getSelectedServiceStatus(); - this._serviceContextButton = view.modelBuilder.button() - .withProps({ - iconPath: IconPathHelper.sqlMigrationService, - iconHeight: 22, - iconWidth: 22, - label: serviceContextLabel, - title: serviceContextLabel, - description: loc.MIGRATION_SERVICE_DESCRIPTION, - buttonType: azdata.ButtonType.Informational, - width: 375, - CSSStyles: { ...BUTTON_CSS }, - }) - .component(); - - this._disposables.push( - this._serviceContextButton.onDidClick(async () => { - const dialog = new SelectMigrationServiceDialog(this.onDialogClosed); - await dialog.initialize(); - })); - - return this._serviceContextButton; - } - - private createVideoLinks(view: azdata.ModelView): azdata.Component { - const linksContainer = view.modelBuilder.flexContainer() - .withLayout({ - flexFlow: 'column', - width: '440px', - height: '385px', - justifyContent: 'flex-start', - }).withProps({ - CSSStyles: { - 'border': '1px solid rgba(0, 0, 0, 0.1)', - 'padding': '10px', - 'overflow': 'scroll', - } - }).component(); - const titleComponent = view.modelBuilder.text().withProps({ - value: loc.HELP_TITLE, - CSSStyles: { - ...styles.SECTION_HEADER_CSS - } - }).component(); - - linksContainer.addItems([titleComponent], { - CSSStyles: { - 'margin-bottom': '16px' - } - }); - - const links = [ - { - title: localize('sql.migration.dashboard.help.link.migrateUsingADS', 'Migrate databases using Azure Data Studio'), - description: localize('sql.migration.dashboard.help.description.migrateUsingADS', 'The Azure SQL Migration extension for Azure Data Studio provides capabilities to assess, get right-sized Azure recommendations and migrate SQL Server databases to Azure.'), - link: 'https://docs.microsoft.com/azure/dms/migration-using-azure-data-studio' - }, - { - title: localize('sql.migration.dashboard.help.link.mi', 'Tutorial: Migrate to Azure SQL Managed Instance (online)'), - description: localize('sql.migration.dashboard.help.description.mi', 'A step-by-step tutorial to migrate databases from a SQL Server instance (on-premises or Azure Virtual Machines) to Azure SQL Managed Instance with minimal downtime.'), - link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-managed-instance-online-ads' - }, - { - title: localize('sql.migration.dashboard.help.link.vm', 'Tutorial: Migrate to SQL Server on Azure Virtual Machines (online)'), - description: localize('sql.migration.dashboard.help.description.vm', 'A step-by-step tutorial to migrate databases from a SQL Server instance (on-premises) to SQL Server on Azure Virtual Machines with minimal downtime.'), - link: 'https://docs.microsoft.com/azure/dms/tutorial-sql-server-to-virtual-machine-online-ads' - }, - { - title: localize('sql.migration.dashboard.help.link.dmsGuide', 'Azure Database Migration Guides'), - description: localize('sql.migration.dashboard.help.description.dmsGuide', 'A hub of migration articles that provides step-by-step guidance for migrating and modernizing your data assets in Azure.'), - link: 'https://docs.microsoft.com/data-migration/' - }, - ]; - - linksContainer.addItems(links.map(l => this.createLink(view, l)), {}); - - const videoLinks: IActionMetadata[] = []; - const videosContainer = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'row', - width: maxWidth, - }).component(); - videosContainer.addItems(videoLinks.map(l => this.createVideoLink(view, l)), {}); - linksContainer.addItem(videosContainer); - - return linksContainer; - } - - private createLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component { - const maxWidth = 400; - const labelsContainer = view.modelBuilder.flexContainer().withProps({ - CSSStyles: { - 'flex-direction': 'column', - 'width': `${maxWidth}px`, - 'justify-content': 'flex-start', - 'margin-bottom': '12px' - } - }).component(); - const linkContainer = view.modelBuilder.flexContainer().withProps({ - CSSStyles: { - 'flex-direction': 'row', - 'width': `${maxWidth}px`, - 'justify-content': 'flex-start', - 'margin-bottom': '4px' - } - - }).component(); - const descriptionComponent = view.modelBuilder.text().withProps({ - value: linkMetaData.description, - width: maxWidth, - CSSStyles: { - ...styles.NOTE_CSS - } - }).component(); - const linkComponent = view.modelBuilder.hyperlink().withProps({ - label: linkMetaData.title!, - url: linkMetaData.link!, - showLinkIcon: true, - CSSStyles: { - ...styles.BODY_CSS - } - }).component(); - linkContainer.addItem(linkComponent); - labelsContainer.addItems([linkContainer, descriptionComponent]); - return labelsContainer; - } - - private createVideoLink(view: azdata.ModelView, linkMetaData: IActionMetadata): azdata.Component { - const maxWidth = 150; - const videosContainer = view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column', - width: maxWidth, - justifyContent: 'flex-start' - }).component(); - const video1Container = view.modelBuilder.divContainer().withProps({ - clickable: true, - width: maxWidth, - height: '100px' - }).component(); - const descriptionComponent = view.modelBuilder.text().withProps({ - value: linkMetaData.description, - width: maxWidth, - height: '50px', - CSSStyles: { - ...styles.BODY_CSS - } - }).component(); - this._disposables.push( - video1Container.onDidClick(async () => { - if (linkMetaData.link) { - await vscode.env.openExternal(vscode.Uri.parse(linkMetaData.link)); - } - })); - videosContainer.addItem(video1Container, { - CSSStyles: { - 'background-image': `url(${vscode.Uri.file(linkMetaData.iconPath?.light)})`, - 'background-repeat': 'no-repeat', - 'background-position': 'top', - 'width': `${maxWidth}px`, - 'height': '104px', - 'background-size': `${maxWidth}px 120px` - } - }); - videosContainer.addItem(descriptionComponent); - return videosContainer; + private async _updateStatusDisplay(control: azdata.Component, visible: boolean): Promise { + await control.updateCssStyles({ 'display': visible ? 'inline' : 'none' }); } } diff --git a/extensions/sql-migration/src/dashboard/tabBase.ts b/extensions/sql-migration/src/dashboard/tabBase.ts new file mode 100644 index 0000000000..2d11c09bc4 --- /dev/null +++ b/extensions/sql-migration/src/dashboard/tabBase.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as loc from '../constants/strings'; +import { IconPathHelper } from '../constants/iconPathHelper'; +import { EOL } from 'os'; +import { DatabaseMigration } from '../api/azure'; +import { DashboardStatusBar } from './sqlServerDashboard'; +import { getSelectedServiceStatus } from '../models/migrationLocalStorage'; +import { util } from 'webpack'; + +export const SqlMigrationExtensionId = 'microsoft.sql-migration'; +export const EmptySettingValue = '-'; + +export enum AdsMigrationStatus { + ALL = 'all', + ONGOING = 'ongoing', + SUCCEEDED = 'succeeded', + FAILED = 'failed', + COMPLETING = 'completing' +} + +export const MenuCommands = { + Cutover: 'sqlmigration.cutover', + ViewDatabase: 'sqlmigration.view.database', + ViewTarget: 'sqlmigration.view.target', + ViewService: 'sqlmigration.view.service', + CopyMigration: 'sqlmigration.copy.migration', + CancelMigration: 'sqlmigration.cancel.migration', + RetryMigration: 'sqlmigration.retry.migration', + StartMigration: 'sqlmigration.start', + IssueReporter: 'workbench.action.openIssueReporter', +}; + +export abstract class TabBase implements azdata.Tab, vscode.Disposable { + public content!: azdata.Component; + public title: string = ''; + public id!: string; + public icon!: azdata.IconPath | undefined; + + protected context!: vscode.ExtensionContext; + protected view!: azdata.ModelView; + protected disposables: vscode.Disposable[] = []; + protected isRefreshing: boolean = false; + protected openMigrationFcn!: (status: AdsMigrationStatus) => Promise; + protected statusBar!: DashboardStatusBar; + + protected abstract initialize(view: azdata.ModelView): Promise; + + public abstract refresh(): Promise; + + dispose() { + this.disposables.forEach( + d => { try { d.dispose(); } catch { } }); + } + + protected numberCompare(number1: number | undefined, number2: number | undefined, sortDir: number): number { + if (!number1) { + return sortDir; + } else if (!number2) { + return -sortDir; + } + return util.comparators.compareNumbers(number1, number2) * -sortDir; + } + + protected stringCompare(string1: string | undefined, string2: string | undefined, sortDir: number): number { + if (!string1) { + return sortDir; + } else if (!string2) { + return -sortDir; + } + return string1.localeCompare(string2) * -sortDir; + } + + protected dateCompare(stringDate1: string | undefined, stringDate2: string | undefined, sortDir: number): number { + if (!stringDate1) { + return sortDir; + } else if (!stringDate2) { + return -sortDir; + } + return new Date(stringDate1) > new Date(stringDate2) ? -sortDir : sortDir; + } + + protected async updateServiceContext(button: azdata.ButtonComponent): Promise { + const label = await getSelectedServiceStatus(); + if (button.label !== label || + button.title !== label) { + + button.label = label; + button.title = label; + + await this.refresh(); + } + } + + protected createNewMigrationButton(): azdata.ButtonComponent { + const newMigrationButton = this.view.modelBuilder.button() + .withProps({ + buttonType: azdata.ButtonType.Normal, + label: loc.DESKTOP_MIGRATION_BUTTON_LABEL, + description: loc.DESKTOP_MIGRATION_BUTTON_DESCRIPTION, + height: 24, + iconHeight: 24, + iconWidth: 24, + iconPath: IconPathHelper.addNew, + }).component(); + this.disposables.push( + newMigrationButton.onDidClick(async () => { + const actionId = MenuCommands.StartMigration; + const args = { + extensionId: SqlMigrationExtensionId, + issueTitle: loc.DASHBOARD_MIGRATE_TASK_BUTTON_TITLE, + }; + return await vscode.commands.executeCommand(actionId, args); + })); + return newMigrationButton; + } + + protected createNewSupportRequestButton(): azdata.ButtonComponent { + const newSupportRequestButton = this.view.modelBuilder.button() + .withProps({ + buttonType: azdata.ButtonType.Normal, + label: loc.DESKTOP_SUPPORT_BUTTON_LABEL, + description: loc.DESKTOP_SUPPORT_BUTTON_DESCRIPTION, + height: 24, + iconHeight: 24, + iconWidth: 24, + iconPath: IconPathHelper.newSupportRequest, + }).component(); + this.disposables.push( + newSupportRequestButton.onDidClick(async () => { + await vscode.env.openExternal(vscode.Uri.parse( + `https://portal.azure.com/#blade/Microsoft_Azure_Support/HelpAndSupportBlade/newsupportrequest`)); + })); + return newSupportRequestButton; + } + + protected createFeedbackButton(): azdata.ButtonComponent { + const feedbackButton = this.view.modelBuilder.button() + .withProps({ + buttonType: azdata.ButtonType.Normal, + label: loc.DESKTOP_FEEDBACK_BUTTON_LABEL, + description: loc.DESKTOP_FEEDBACK_BUTTON_DESCRIPTION, + height: 24, + iconHeight: 24, + iconWidth: 24, + iconPath: IconPathHelper.sendFeedback, + }).component(); + this.disposables.push( + feedbackButton.onDidClick(async () => { + const actionId = MenuCommands.IssueReporter; + const args = { + extensionId: SqlMigrationExtensionId, + issueTitle: loc.FEEDBACK_ISSUE_TITLE, + }; + return await vscode.commands.executeCommand(actionId, args); + })); + return feedbackButton; + } + + protected getMigrationErrors(migration: DatabaseMigration): string { + const errors = []; + errors.push(migration.properties.provisioningError); + errors.push(migration.properties.migrationFailureError?.message); + errors.push(...migration.properties.migrationStatusDetails?.fileUploadBlockingErrors ?? []); + errors.push(migration.properties.migrationStatusDetails?.restoreBlockingReason); + + // remove undefined and duplicate error entries + return errors + .filter((e, i, arr) => e !== undefined && i === arr.indexOf(e)) + .join(EOL); + } + + protected showDialogMessage( + title: string, + statusMessage: string, + errorMessage: string, + ): void { + const tab = azdata.window.createTab(title); + tab.registerContent(async (view) => { + const flex = view.modelBuilder.flexContainer() + .withItems([ + view.modelBuilder.text() + .withProps({ value: statusMessage }) + .component(), + ]) + .withLayout({ + flexFlow: 'column', + width: 420, + }) + .withProps({ CSSStyles: { 'margin': '0 15px' } }) + .component(); + + if (errorMessage.length > 0) { + flex.addItem( + view.modelBuilder.inputBox() + .withProps({ + value: errorMessage, + readOnly: true, + multiline: true, + inputType: 'text', + height: 100, + CSSStyles: { 'overflow': 'hidden auto' }, + }) + .component() + ); + } + + await view.initializeModel(flex); + }); + + const dialog = azdata.window.createModelViewDialog( + title, + 'messageDialog', + 450, + 'normal'); + dialog.content = [tab]; + dialog.okButton.hidden = true; + dialog.cancelButton.focused = true; + dialog.cancelButton.label = loc.CLOSE; + + azdata.window.openDialog(dialog); + } +} diff --git a/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts b/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts index bfc91795e9..25c5448b78 100644 --- a/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationCutover/confirmCutoverDialog.ts @@ -11,7 +11,8 @@ import { getMigrationTargetInstance, SqlManagedInstance } from '../../api/azure' import { IconPathHelper } from '../../constants/iconPathHelper'; import { convertByteSizeToReadableUnit, get12HourTime } from '../../api/utils'; import * as styles from '../../constants/styles'; -import { isBlobMigration } from '../../constants/helper'; +import { getMigrationTargetTypeEnum, isBlobMigration } from '../../constants/helper'; +import { MigrationTargetType, ServiceTier } from '../../models/stateMachine'; export class ConfirmCutoverDialog { private _dialogObject!: azdata.window.Dialog; private _view!: azdata.ModelView; @@ -32,7 +33,7 @@ export class ConfirmCutoverDialog { }).component(); const sourceDatabaseText = view.modelBuilder.text().withProps({ - value: this.migrationCutoverModel._migration.properties.sourceDatabaseName, + value: this.migrationCutoverModel.migration.properties.sourceDatabaseName, CSSStyles: { ...styles.SMALL_NOTE_CSS, 'margin': '4px 0px 8px' @@ -53,7 +54,7 @@ export class ConfirmCutoverDialog { } }).component(); - const fileContainer = isBlobMigration(this.migrationCutoverModel.migrationStatus) + const fileContainer = isBlobMigration(this.migrationCutoverModel.migration) ? this.createBlobFileContainer() : this.createNetworkShareFileContainer(); @@ -76,13 +77,13 @@ export class ConfirmCutoverDialog { }).component(); let infoDisplay = 'none'; - if (this.migrationCutoverModel._migration.id.toLocaleLowerCase().includes('managedinstances')) { + if (getMigrationTargetTypeEnum(this.migrationCutoverModel.migration) === MigrationTargetType.SQLMI) { const targetInstance = await getMigrationTargetInstance( - this.migrationCutoverModel._serviceConstext.azureAccount!, - this.migrationCutoverModel._serviceConstext.subscription!, - this.migrationCutoverModel._migration); + this.migrationCutoverModel.serviceConstext.azureAccount!, + this.migrationCutoverModel.serviceConstext.subscription!, + this.migrationCutoverModel.migration); - if ((targetInstance)?.sku?.tier === 'BusinessCritical') { + if ((targetInstance)?.sku?.tier === ServiceTier.BusinessCritical) { infoDisplay = 'inline'; } } @@ -116,7 +117,7 @@ export class ConfirmCutoverDialog { await this.migrationCutoverModel.startCutover(); void vscode.window.showInformationMessage( constants.CUTOVER_IN_PROGRESS( - this.migrationCutoverModel._migration.properties.sourceDatabaseName)); + this.migrationCutoverModel.migration.properties.sourceDatabaseName)); })); const formBuilder = view.modelBuilder.formContainer().withFormItems( @@ -163,7 +164,7 @@ export class ConfirmCutoverDialog { } catch (e) { this._dialogObject.message = { level: azdata.window.MessageLevel.Error, - text: e.toString() + text: e.message }; } finally { refreshLoader.loading = false; @@ -241,7 +242,7 @@ export class ConfirmCutoverDialog { } catch (e) { this._dialogObject.message = { level: azdata.window.MessageLevel.Error, - text: e.toString() + text: e.message }; } finally { refreshLoader.loading = false; diff --git a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts deleted file mode 100644 index b34828b8b9..0000000000 --- a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialog.ts +++ /dev/null @@ -1,852 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { BackupFileInfoStatus, MigrationServiceContext, MigrationStatus } from '../../models/migrationLocalStorage'; -import { MigrationCutoverDialogModel } from './migrationCutoverDialogModel'; -import * as loc from '../../constants/strings'; -import { convertByteSizeToReadableUnit, convertIsoTimeToLocalTime, getSqlServerName, getMigrationStatusImage, clearDialogMessage, displayDialogErrorMessage } from '../../api/utils'; -import { EOL } from 'os'; -import { ConfirmCutoverDialog } from './confirmCutoverDialog'; -import { logError, TelemetryViews } from '../../telemtery'; -import { RetryMigrationDialog } from '../retryMigration/retryMigrationDialog'; -import * as styles from '../../constants/styles'; -import { canRetryMigration, getMigrationStatus, isBlobMigration, isOfflineMigation } from '../../constants/helper'; -import { DatabaseMigration, getResourceName } from '../../api/azure'; - -const statusImageSize: number = 14; - -export class MigrationCutoverDialog { - private _dialogObject!: azdata.window.Dialog; - private _view!: azdata.ModelView; - private _model: MigrationCutoverDialogModel; - - private _databaseTitleName!: azdata.TextComponent; - private _cutoverButton!: azdata.ButtonComponent; - private _refreshButton!: azdata.ButtonComponent; - private _cancelButton!: azdata.ButtonComponent; - private _refreshLoader!: azdata.LoadingComponent; - private _copyDatabaseMigrationDetails!: azdata.ButtonComponent; - private _newSupportRequest!: azdata.ButtonComponent; - private _retryButton!: azdata.ButtonComponent; - - private _sourceDatabaseInfoField!: InfoFieldSchema; - private _sourceDetailsInfoField!: InfoFieldSchema; - private _sourceVersionInfoField!: InfoFieldSchema; - private _targetDatabaseInfoField!: InfoFieldSchema; - private _targetServerInfoField!: InfoFieldSchema; - private _targetVersionInfoField!: InfoFieldSchema; - private _migrationStatusInfoField!: InfoFieldSchema; - private _fullBackupFileOnInfoField!: InfoFieldSchema; - private _backupLocationInfoField!: InfoFieldSchema; - private _lastLSNInfoField!: InfoFieldSchema; - private _lastAppliedBackupInfoField!: InfoFieldSchema; - private _lastAppliedBackupTakenOnInfoField!: InfoFieldSchema; - private _currentRestoringFileInfoField!: InfoFieldSchema; - - private _fileCount!: azdata.TextComponent; - private _fileTable!: azdata.DeclarativeTableComponent; - private _disposables: vscode.Disposable[] = []; - private _emptyTableFill!: azdata.FlexContainer; - - private isRefreshing = false; - - readonly _infoFieldWidth: string = '250px'; - - constructor( - private readonly _context: vscode.ExtensionContext, - private readonly _serviceContext: MigrationServiceContext, - private readonly _migration: DatabaseMigration, - private readonly _onClosedCallback: () => Promise) { - - this._model = new MigrationCutoverDialogModel(_serviceContext, _migration); - this._dialogObject = azdata.window.createModelViewDialog('', 'MigrationCutoverDialog', 'wide'); - } - - async initialize(): Promise { - let tab = azdata.window.createTab(''); - tab.registerContent(async (view: azdata.ModelView) => { - try { - this._view = view; - - this._fileCount = view.modelBuilder.text().withProps({ - width: '500px', - CSSStyles: { - ...styles.BODY_CSS - } - }).component(); - - const rowCssStyle: azdata.CssStyles = { - 'border': 'none', - 'text-align': 'left', - 'border-bottom': '1px solid', - 'font-size': '12px' - }; - - const headerCssStyles: azdata.CssStyles = { - 'border': 'none', - 'text-align': 'left', - 'border-bottom': '1px solid', - 'font-weight': 'bold', - 'padding-left': '0px', - 'padding-right': '0px', - 'font-size': '12px' - }; - - this._fileTable = view.modelBuilder.declarativeTable().withProps({ - ariaLabel: loc.ACTIVE_BACKUP_FILES, - columns: [ - { - displayName: loc.ACTIVE_BACKUP_FILES, - valueType: azdata.DeclarativeDataType.string, - width: '230px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.TYPE, - valueType: azdata.DeclarativeDataType.string, - width: '90px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.STATUS, - valueType: azdata.DeclarativeDataType.string, - width: '60px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.DATA_UPLOADED, - valueType: azdata.DeclarativeDataType.string, - width: '120px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.COPY_THROUGHPUT, - valueType: azdata.DeclarativeDataType.string, - width: '150px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.BACKUP_START_TIME, - valueType: azdata.DeclarativeDataType.string, - width: '130px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.FIRST_LSN, - valueType: azdata.DeclarativeDataType.string, - width: '120px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - }, - { - displayName: loc.LAST_LSN, - valueType: azdata.DeclarativeDataType.string, - width: '120px', - isReadOnly: true, - rowCssStyles: rowCssStyle, - headerCssStyles: headerCssStyles - } - ], - data: [], - width: '1100px', - height: '300px', - CSSStyles: { - ...styles.BODY_CSS, - 'display': 'none', - 'padding-left': '0px' - } - }).component(); - - const _emptyTableImage = view.modelBuilder.image().withProps({ - iconPath: IconPathHelper.emptyTable, - iconHeight: '100px', - iconWidth: '100px', - height: '100px', - width: '100px', - CSSStyles: { - 'text-align': 'center' - } - }).component(); - - const _emptyTableText = view.modelBuilder.text().withProps({ - value: loc.EMPTY_TABLE_TEXT, - CSSStyles: { - ...styles.NOTE_CSS, - 'margin-top': '8px', - 'text-align': 'center', - 'width': '300px' - } - }).component(); - - this._emptyTableFill = view.modelBuilder.flexContainer() - .withLayout({ - flexFlow: 'column', - alignItems: 'center' - }).withItems([ - _emptyTableImage, - _emptyTableText, - ]).withProps({ - width: 1000, - display: 'none' - }).component(); - - let formItems = [ - { component: this.migrationContainerHeader() }, - { component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() }, - { component: await this.migrationInfoGrid() }, - { component: this._view.modelBuilder.separator().withProps({ width: 1000 }).component() }, - { component: this._fileCount }, - { component: this._fileTable }, - { component: this._emptyTableFill } - ]; - - const formBuilder = view.modelBuilder.formContainer().withFormItems( - formItems, - { horizontal: false } - ); - const form = formBuilder.withLayout({ width: '100%' }).component(); - - this._disposables.push( - this._view.onClosed(e => { - this._disposables.forEach( - d => { try { d.dispose(); } catch { } }); - })); - - await view.initializeModel(form); - await this.refreshStatus(); - } catch (e) { - logError(TelemetryViews.MigrationCutoverDialog, 'IntializingFailed', e); - } - }); - this._dialogObject.content = [tab]; - - this._dialogObject.cancelButton.hidden = true; - this._dialogObject.okButton.label = loc.CLOSE; - - azdata.window.openDialog(this._dialogObject); - } - - private migrationContainerHeader(): azdata.FlexContainer { - const sqlDatbaseLogo = this._view.modelBuilder.image().withProps({ - iconPath: IconPathHelper.sqlDatabaseLogo, - iconHeight: '32px', - iconWidth: '32px', - width: '32px', - height: '32px' - }).component(); - - this._databaseTitleName = this._view.modelBuilder.text().withProps({ - CSSStyles: { - ...styles.PAGE_TITLE_CSS - }, - width: 950, - value: this._model._migration.properties.sourceDatabaseName - }).component(); - - const databaseSubTitle = this._view.modelBuilder.text().withProps({ - CSSStyles: { - ...styles.NOTE_CSS - }, - width: 950, - value: loc.DATABASE - }).component(); - - const titleContainer = this._view.modelBuilder.flexContainer().withItems([ - this._databaseTitleName, - databaseSubTitle - ]).withLayout({ - 'flexFlow': 'column' - }).withProps({ - width: 950 - }).component(); - - const titleLogoContainer = this._view.modelBuilder.flexContainer().withProps({ - width: 1000 - }).component(); - - titleLogoContainer.addItem(sqlDatbaseLogo, { - flex: '0' - }); - titleLogoContainer.addItem(titleContainer, { - flex: '0', - CSSStyles: { - 'margin-left': '5px', - 'width': '930px' - } - }); - - const headerActions = this._view.modelBuilder.flexContainer().withLayout({ - }).withProps({ - width: 1000 - }).component(); - - this._cutoverButton = this._view.modelBuilder.button().withProps({ - iconPath: IconPathHelper.cutover, - iconHeight: '16px', - iconWidth: '16px', - label: loc.COMPLETE_CUTOVER, - height: '20px', - width: '140px', - enabled: false, - CSSStyles: { - ...styles.BODY_CSS, - 'display': isOfflineMigation(this._model._migration) ? 'none' : 'block' - } - }).component(); - - this._disposables.push(this._cutoverButton.onDidClick(async (e) => { - await this.refreshStatus(); - const dialog = new ConfirmCutoverDialog(this._model); - await dialog.initialize(); - - if (this._model.CutoverError) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_CUTOVER_ERROR, this._model.CutoverError); - } - })); - - headerActions.addItem(this._cutoverButton, { flex: '0' }); - - this._cancelButton = this._view.modelBuilder.button().withProps({ - iconPath: IconPathHelper.cancel, - iconHeight: '16px', - iconWidth: '16px', - label: loc.CANCEL_MIGRATION, - height: '20px', - width: '140px', - enabled: false, - CSSStyles: { - ...styles.BODY_CSS, - } - }).component(); - - this._disposables.push(this._cancelButton.onDidClick((e) => { - void vscode.window.showInformationMessage(loc.CANCEL_MIGRATION_CONFIRMATION, { modal: true }, loc.YES, loc.NO).then(async (v) => { - if (v === loc.YES) { - await this._model.cancelMigration(); - await this.refreshStatus(); - if (this._model.CancelMigrationError) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_CANCELLATION_ERROR, this._model.CancelMigrationError); - } - } - }); - })); - - headerActions.addItem(this._cancelButton, { - flex: '0' - }); - - this._retryButton = this._view.modelBuilder.button().withProps({ - label: loc.RETRY_MIGRATION, - iconPath: IconPathHelper.retry, - enabled: false, - iconHeight: '16px', - iconWidth: '16px', - height: '20px', - width: '120px', - CSSStyles: { - ...styles.BODY_CSS, - } - }).component(); - this._disposables.push(this._retryButton.onDidClick( - async (e) => { - await this.refreshStatus(); - const retryMigrationDialog = new RetryMigrationDialog( - this._context, - this._serviceContext, - this._migration, - this._onClosedCallback); - await retryMigrationDialog.openDialog(); - } - )); - headerActions.addItem(this._retryButton, { - flex: '0', - }); - - this._refreshButton = this._view.modelBuilder.button().withProps({ - iconPath: IconPathHelper.refresh, - iconHeight: '16px', - iconWidth: '16px', - label: 'Refresh', - height: '20px', - width: '80px', - CSSStyles: { - ...styles.BODY_CSS, - } - }).component(); - - this._disposables.push( - this._refreshButton.onDidClick(async (e) => { - this._refreshButton.enabled = false; - await this.refreshStatus(); - this._refreshButton.enabled = true; - })); - - headerActions.addItem(this._refreshButton, { flex: '0' }); - - this._copyDatabaseMigrationDetails = this._view.modelBuilder.button().withProps({ - iconPath: IconPathHelper.copy, - iconHeight: '16px', - iconWidth: '16px', - label: loc.COPY_MIGRATION_DETAILS, - height: '20px', - width: '160px', - CSSStyles: { - ...styles.BODY_CSS, - } - }).component(); - - this._disposables.push(this._copyDatabaseMigrationDetails.onDidClick(async (e) => { - await this.refreshStatus(); - await vscode.env.clipboard.writeText(this.getMigrationDetails()); - - void vscode.window.showInformationMessage(loc.DETAILS_COPIED); - })); - - headerActions.addItem(this._copyDatabaseMigrationDetails, { - flex: '0', - CSSStyles: { 'margin-left': '5px' } - }); - - // create new support request button. Hiding button until sql migration support has been setup. - this._newSupportRequest = this._view.modelBuilder.button().withProps({ - label: loc.NEW_SUPPORT_REQUEST, - iconPath: IconPathHelper.newSupportRequest, - iconHeight: '16px', - iconWidth: '16px', - height: '20px', - width: '160px', - CSSStyles: { - ...styles.BODY_CSS, - } - }).component(); - - this._disposables.push(this._newSupportRequest.onDidClick(async (e) => { - const serviceId = this._model._migration.properties.migrationService; - const supportUrl = `https://portal.azure.com/#resource${serviceId}/supportrequest`; - await vscode.env.openExternal(vscode.Uri.parse(supportUrl)); - })); - - headerActions.addItem(this._newSupportRequest, { - flex: '0', - CSSStyles: { - 'margin-left': '5px' - } - }); - - this._refreshLoader = this._view.modelBuilder.loadingComponent().withProps({ - loading: false, - CSSStyles: { - 'height': '8px', - 'margin-top': '4px' - } - }).component(); - - headerActions.addItem(this._refreshLoader, { - flex: '0', - CSSStyles: { - 'margin-left': '16px' - } - }); - - const header = this._view.modelBuilder.flexContainer().withItems([ - titleLogoContainer - ]).withLayout({ - flexFlow: 'column' - }).withProps({ - CSSStyles: { - width: 1000 - } - }).component(); - - header.addItem(headerActions, { - 'CSSStyles': { - 'margin-top': '16px' - } - }); - - return header; - } - - private async migrationInfoGrid(): Promise { - const addInfoFieldToContainer = (infoField: InfoFieldSchema, container: azdata.FlexContainer): void => { - container.addItem(infoField.flexContainer, { - CSSStyles: { - width: this._infoFieldWidth, - } - }); - }; - - const flexServer = this._view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - this._sourceDatabaseInfoField = await this.createInfoField(loc.SOURCE_DATABASE, ''); - this._sourceDetailsInfoField = await this.createInfoField(loc.SOURCE_SERVER, ''); - this._sourceVersionInfoField = await this.createInfoField(loc.SOURCE_VERSION, ''); - addInfoFieldToContainer(this._sourceDatabaseInfoField, flexServer); - addInfoFieldToContainer(this._sourceDetailsInfoField, flexServer); - addInfoFieldToContainer(this._sourceVersionInfoField, flexServer); - - const flexTarget = this._view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - this._targetDatabaseInfoField = await this.createInfoField(loc.TARGET_DATABASE_NAME, ''); - this._targetServerInfoField = await this.createInfoField(loc.TARGET_SERVER, ''); - this._targetVersionInfoField = await this.createInfoField(loc.TARGET_VERSION, ''); - addInfoFieldToContainer(this._targetDatabaseInfoField, flexTarget); - addInfoFieldToContainer(this._targetServerInfoField, flexTarget); - addInfoFieldToContainer(this._targetVersionInfoField, flexTarget); - - const _isBlobMigration = isBlobMigration(this._model._migration); - const flexStatus = this._view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - this._migrationStatusInfoField = await this.createInfoField(loc.MIGRATION_STATUS, '', false, ' '); - this._fullBackupFileOnInfoField = await this.createInfoField(loc.FULL_BACKUP_FILES, '', _isBlobMigration); - this._backupLocationInfoField = await this.createInfoField(loc.BACKUP_LOCATION, ''); - addInfoFieldToContainer(this._migrationStatusInfoField, flexStatus); - addInfoFieldToContainer(this._fullBackupFileOnInfoField, flexStatus); - addInfoFieldToContainer(this._backupLocationInfoField, flexStatus); - - const flexFile = this._view.modelBuilder.flexContainer().withLayout({ - flexFlow: 'column' - }).component(); - this._lastLSNInfoField = await this.createInfoField(loc.LAST_APPLIED_LSN, '', _isBlobMigration); - this._lastAppliedBackupInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES, ''); - this._lastAppliedBackupTakenOnInfoField = await this.createInfoField(loc.LAST_APPLIED_BACKUP_FILES_TAKEN_ON, '', _isBlobMigration); - this._currentRestoringFileInfoField = await this.createInfoField(loc.CURRENTLY_RESTORING_FILE, '', !_isBlobMigration); - addInfoFieldToContainer(this._lastLSNInfoField, flexFile); - addInfoFieldToContainer(this._lastAppliedBackupInfoField, flexFile); - addInfoFieldToContainer(this._lastAppliedBackupTakenOnInfoField, flexFile); - addInfoFieldToContainer(this._currentRestoringFileInfoField, flexFile); - - const flexInfoProps = { - flex: '0', - CSSStyles: { - 'flex': '0', - 'width': this._infoFieldWidth - } - }; - - const flexInfo = this._view.modelBuilder.flexContainer().withProps({ - width: 1000 - }).component(); - flexInfo.addItem(flexServer, flexInfoProps); - flexInfo.addItem(flexTarget, flexInfoProps); - flexInfo.addItem(flexStatus, flexInfoProps); - flexInfo.addItem(flexFile, flexInfoProps); - - return flexInfo; - } - - private getMigrationDetails(): string { - return JSON.stringify(this._model.migrationStatus, undefined, 2); - } - - private async refreshStatus(): Promise { - if (this.isRefreshing) { - return; - } - - try { - clearDialogMessage(this._dialogObject); - - await this._cutoverButton.updateCssStyles( - { 'display': isOfflineMigation(this._model._migration) ? 'none' : 'block' }); - - this.isRefreshing = true; - this._refreshLoader.loading = true; - await this._model.fetchStatus(); - const errors = []; - errors.push(this._model.migrationStatus.properties.provisioningError); - errors.push(this._model.migrationStatus.properties.migrationFailureError?.message); - errors.push(this._model.migrationStatus.properties.migrationStatusDetails?.fileUploadBlockingErrors ?? []); - errors.push(this._model.migrationStatus.properties.migrationStatusDetails?.restoreBlockingReason); - this._dialogObject.message = { - // remove undefined and duplicate error entries - text: errors - .filter((e, i, arr) => e !== undefined && i === arr.indexOf(e)) - .join(EOL), - level: this._model.migrationStatus.properties.migrationStatus === MigrationStatus.InProgress - || this._model.migrationStatus.properties.migrationStatus === MigrationStatus.Completing - ? azdata.window.MessageLevel.Warning - : azdata.window.MessageLevel.Error, - description: this.getMigrationDetails() - }; - const sqlServerInfo = await azdata.connection.getServerInfo((await azdata.connection.getCurrentConnection()).connectionId); - const sqlServerName = this._model._migration.properties.sourceServerName; - const sourceDatabaseName = this._model._migration.properties.sourceDatabaseName; - const versionName = getSqlServerName(sqlServerInfo.serverMajorVersion!); - const sqlServerVersion = versionName ? versionName : sqlServerInfo.serverVersion; - const targetDatabaseName = this._model._migration.name; - const targetServerName = getResourceName(this._model._migration.properties.scope); - let targetServerVersion; - if (this._model.migrationStatus.id.includes('managedInstances')) { - targetServerVersion = loc.AZURE_SQL_DATABASE_MANAGED_INSTANCE; - } else { - targetServerVersion = loc.AZURE_SQL_DATABASE_VIRTUAL_MACHINE; - } - - let lastAppliedSSN: string; - let lastAppliedBackupFileTakenOn: string; - - const tableData: ActiveBackupFileSchema[] = []; - - this._model.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.forEach( - (activeBackupSet) => { - if (this._shouldDisplayBackupFileTable()) { - tableData.push( - ...activeBackupSet.listOfBackupFiles.map(f => { - return { - fileName: f.fileName, - type: activeBackupSet.backupType, - status: f.status, - dataUploaded: `${convertByteSizeToReadableUnit(f.dataWritten)} / ${convertByteSizeToReadableUnit(f.totalSize)}`, - copyThroughput: (f.copyThroughput) ? (f.copyThroughput / 1024).toFixed(2) : '-', - backupStartTime: activeBackupSet.backupStartDate, - firstLSN: activeBackupSet.firstLSN, - lastLSN: activeBackupSet.lastLSN - }; - }) - ); - } - - if (activeBackupSet.listOfBackupFiles[0].fileName === this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename) { - lastAppliedSSN = activeBackupSet.lastLSN; - lastAppliedBackupFileTakenOn = activeBackupSet.backupFinishDate; - } - }); - - this._sourceDatabaseInfoField.text.value = sourceDatabaseName; - this._sourceDetailsInfoField.text.value = sqlServerName; - this._sourceVersionInfoField.text.value = `${sqlServerVersion} ${sqlServerInfo.serverVersion}`; - - this._targetDatabaseInfoField.text.value = targetDatabaseName; - this._targetServerInfoField.text.value = targetServerName; - this._targetVersionInfoField.text.value = targetServerVersion; - - const migrationStatusTextValue = this._getMigrationStatus(); - this._migrationStatusInfoField.text.value = migrationStatusTextValue ?? '-'; - this._migrationStatusInfoField.icon!.iconPath = getMigrationStatusImage(migrationStatusTextValue); - - this._fullBackupFileOnInfoField.text.value = this._model.migrationStatus?.properties?.migrationStatusDetails?.fullBackupSetInfo?.listOfBackupFiles[0]?.fileName! ?? '-'; - - let backupLocation; - const _isBlobMigration = isBlobMigration(this._model._migration); - // Displaying storage accounts and blob container for azure blob backups. - if (_isBlobMigration) { - const storageAccountResourceId = this._model._migration.properties.backupConfiguration?.sourceLocation?.azureBlob?.storageAccountResourceId; - const blobContainerName = this._model._migration.properties.backupConfiguration?.sourceLocation?.azureBlob?.blobContainerName; - backupLocation = storageAccountResourceId && blobContainerName - ? `${storageAccountResourceId?.split('/').pop()} - ${blobContainerName}` - : undefined; - } else { - const fileShare = this._model._migration.properties.backupConfiguration?.sourceLocation?.fileShare; - backupLocation = fileShare?.path! ?? '-'; - } - this._backupLocationInfoField.text.value = backupLocation ?? '-'; - - this._lastLSNInfoField.text.value = lastAppliedSSN! ?? '-'; - this._lastAppliedBackupInfoField.text.value = this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename ?? '-'; - this._lastAppliedBackupTakenOnInfoField.text.value = lastAppliedBackupFileTakenOn! ? convertIsoTimeToLocalTime(lastAppliedBackupFileTakenOn).toLocaleString() : '-'; - - if (_isBlobMigration) { - if (!this._model.migrationStatus.properties.migrationStatusDetails?.currentRestoringFilename) { - this._currentRestoringFileInfoField.text.value = '-'; - } else if (this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename === this._model.migrationStatus.properties.migrationStatusDetails?.currentRestoringFilename) { - this._currentRestoringFileInfoField.text.value = loc.ALL_BACKUPS_RESTORED; - } else { - this._currentRestoringFileInfoField.text.value = this._model.migrationStatus.properties.migrationStatusDetails?.currentRestoringFilename; - } - } - - if (this._shouldDisplayBackupFileTable()) { - await this._fileCount.updateCssStyles({ - ...styles.SECTION_HEADER_CSS, - display: 'inline' - }); - await this._fileTable.updateCssStyles({ - display: 'inline' - }); - - this._fileCount.value = loc.ACTIVE_BACKUP_FILES_ITEMS(tableData.length); - - if (tableData.length === 0) { - await this._emptyTableFill.updateCssStyles({ - 'display': 'flex' - }); - this._fileTable.height = '50px'; - } else { - await this._emptyTableFill.updateCssStyles({ - 'display': 'none' - }); - this._fileTable.height = '300px'; - - // Sorting files in descending order of backupStartTime - tableData.sort((file1, file2) => new Date(file1.backupStartTime) > new Date(file2.backupStartTime) ? - 1 : 1); - - this._fileTable.data = tableData.map((row) => { - return [ - row.fileName, - row.type, - row.status, - row.dataUploaded, - row.copyThroughput, - convertIsoTimeToLocalTime(row.backupStartTime).toLocaleString(), - row.firstLSN, - row.lastLSN - ]; - }); - } - } - - this._cutoverButton.enabled = false; - if (migrationStatusTextValue === MigrationStatus.InProgress) { - if (_isBlobMigration) { - if (this._model.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename) { - this._cutoverButton.enabled = true; - } - } else { - const restoredCount = this._model.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.filter( - (a) => a.listOfBackupFiles[0].status === BackupFileInfoStatus.Restored)?.length ?? 0; - if (restoredCount > 0) { - this._cutoverButton.enabled = true; - } - } - } - - this._cancelButton.enabled = - migrationStatusTextValue === MigrationStatus.Creating || - migrationStatusTextValue === MigrationStatus.InProgress; - - this._retryButton.enabled = canRetryMigration(migrationStatusTextValue); - - } catch (e) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_STATUS_REFRESH_ERROR, e); - console.log(e); - } finally { - this.isRefreshing = false; - this._refreshLoader.loading = false; - } - } - - private async createInfoField(label: string, value: string, defaultHidden: boolean = false, iconPath?: azdata.IconPath): Promise<{ - flexContainer: azdata.FlexContainer, - text: azdata.TextComponent, - icon?: azdata.ImageComponent - }> { - const flexContainer = this._view.modelBuilder.flexContainer() - .withProps({ - CSSStyles: { - 'flex-direction': 'column', - 'padding-right': '12px' - } - }).component(); - - if (defaultHidden) { - await flexContainer.updateCssStyles({ - 'display': 'none' - }); - } - - const labelComponent = this._view.modelBuilder.text().withProps({ - value: label, - CSSStyles: { - ...styles.LIGHT_LABEL_CSS, - 'margin-bottom': '0', - } - }).component(); - flexContainer.addItem(labelComponent); - - const textComponent = this._view.modelBuilder.text().withProps({ - value: value, - CSSStyles: { - ...styles.BODY_CSS, - 'margin': '4px 0 12px', - 'width': '100%', - 'overflow': 'visible', - 'overflow-wrap': 'break-word' - } - }).component(); - - let iconComponent; - if (iconPath) { - iconComponent = this._view.modelBuilder.image().withProps({ - iconPath: (iconPath === ' ') ? undefined : iconPath, - iconHeight: statusImageSize, - iconWidth: statusImageSize, - height: statusImageSize, - width: statusImageSize, - CSSStyles: { - 'margin': '7px 3px 0 0', - 'padding': '0' - } - }).component(); - - const iconTextComponent = this._view.modelBuilder.flexContainer() - .withItems([ - iconComponent, - textComponent - ]).withProps({ - CSSStyles: { - 'margin': '0', - 'padding': '0' - }, - display: 'inline-flex' - }).component(); - flexContainer.addItem(iconTextComponent); - } else { - flexContainer.addItem(textComponent); - } - - return { - flexContainer: flexContainer, - text: textComponent, - icon: iconComponent - }; - } - - private _shouldDisplayBackupFileTable(): boolean { - return !isBlobMigration(this._model._migration); - } - - private _getMigrationStatus(): string { - return this._model.migrationStatus - ? getMigrationStatus(this._model.migrationStatus) - : getMigrationStatus(this._model._migration); - } -} - -interface ActiveBackupFileSchema { - fileName: string, - type: string, - status: string, - dataUploaded: string, - copyThroughput: string, - backupStartTime: string, - firstLSN: string, - lastLSN: string -} - -interface InfoFieldSchema { - flexContainer: azdata.FlexContainer, - text: azdata.TextComponent, - icon?: azdata.ImageComponent -} diff --git a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts index 196b04b3a1..b80661f797 100644 --- a/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts +++ b/extensions/sql-migration/src/dialog/migrationCutover/migrationCutoverDialogModel.ts @@ -7,53 +7,45 @@ import { DatabaseMigration, startMigrationCutover, stopMigration, BackupFileInfo import { BackupFileInfoStatus, MigrationServiceContext } from '../../models/migrationLocalStorage'; import { logError, sendSqlMigrationActionEvent, TelemetryAction, TelemetryViews } from '../../telemtery'; import * as constants from '../../constants/strings'; -import { EOL } from 'os'; import { getMigrationTargetType, getMigrationMode, isBlobMigration } from '../../constants/helper'; export class MigrationCutoverDialogModel { public CutoverError?: Error; public CancelMigrationError?: Error; - public migrationStatus!: DatabaseMigration; - constructor( - public _serviceConstext: MigrationServiceContext, - public _migration: DatabaseMigration - ) { - } + public serviceConstext: MigrationServiceContext, + public migration: DatabaseMigration) { } public async fetchStatus(): Promise { - this.migrationStatus = await getMigrationDetails( - this._serviceConstext.azureAccount!, - this._serviceConstext.subscription!, - this._migration.id, - this._migration.properties?.migrationOperationId); + const migrationStatus = await getMigrationDetails( + this.serviceConstext.azureAccount!, + this.serviceConstext.subscription!, + this.migration.id, + this.migration.properties?.migrationOperationId); sendSqlMigrationActionEvent( TelemetryViews.MigrationCutoverDialog, TelemetryAction.MigrationStatus, - { - 'migrationStatus': this.migrationStatus.properties?.migrationStatus - }, - {} - ); - // Logging status to help debugging. - console.log(this.migrationStatus); + { 'migrationStatus': migrationStatus.properties?.migrationStatus }, + {}); + + this.migration = migrationStatus; } public async startCutover(): Promise { try { this.CutoverError = undefined; - if (this._migration) { + if (this.migration) { const cutover = await startMigrationCutover( - this._serviceConstext.azureAccount!, - this._serviceConstext.subscription!, - this._migration!); + this.serviceConstext.azureAccount!, + this.serviceConstext.subscription!, + this.migration!); sendSqlMigrationActionEvent( TelemetryViews.MigrationCutoverDialog, TelemetryAction.CutoverMigration, { - ...this.getTelemetryProps(this._serviceConstext, this._migration), + ...this.getTelemetryProps(this.serviceConstext, this.migration), 'migrationEndTime': new Date().toString(), }, {} @@ -67,30 +59,21 @@ export class MigrationCutoverDialogModel { return undefined!; } - public async fetchErrors(): Promise { - const errors = []; - await this.fetchStatus(); - errors.push(this.migrationStatus.properties.migrationFailureError?.message); - return errors - .filter((e, i, arr) => e !== undefined && i === arr.indexOf(e)) - .join(EOL); - } - public async cancelMigration(): Promise { try { this.CancelMigrationError = undefined; - if (this.migrationStatus) { + if (this.migration) { const cutoverStartTime = new Date().toString(); await stopMigration( - this._serviceConstext.azureAccount!, - this._serviceConstext.subscription!, - this.migrationStatus); + this.serviceConstext.azureAccount!, + this.serviceConstext.subscription!, + this.migration); sendSqlMigrationActionEvent( TelemetryViews.MigrationCutoverDialog, TelemetryAction.CancelMigration, { - ...this.getTelemetryProps(this._serviceConstext, this._migration), - 'migrationMode': getMigrationMode(this._migration), + ...this.getTelemetryProps(this.serviceConstext, this.migration), + 'migrationMode': getMigrationMode(this.migration), 'cutoverStartTime': cutoverStartTime, }, {} @@ -104,7 +87,7 @@ export class MigrationCutoverDialogModel { } public confirmCutoverStepsString(): string { - if (isBlobMigration(this.migrationStatus)) { + if (isBlobMigration(this.migration)) { return `${constants.CUTOVER_HELP_STEP1} ${constants.CUTOVER_HELP_STEP2_BLOB_CONTAINER} ${constants.CUTOVER_HELP_STEP3_BLOB_CONTAINER}`; @@ -116,16 +99,16 @@ export class MigrationCutoverDialogModel { } public getLastBackupFileRestoredName(): string | undefined { - return this.migrationStatus.properties.migrationStatusDetails?.lastRestoredFilename; + return this.migration.properties.migrationStatusDetails?.lastRestoredFilename; } public getPendingLogBackupsCount(): number | undefined { - return this.migrationStatus.properties.migrationStatusDetails?.pendingLogBackupsCount; + return this.migration.properties.migrationStatusDetails?.pendingLogBackupsCount; } public getPendingFiles(): BackupFileInfo[] { const files: BackupFileInfo[] = []; - this.migrationStatus.properties.migrationStatusDetails?.activeBackupSets?.forEach(abs => { + this.migration.properties.migrationStatusDetails?.activeBackupSets?.forEach(abs => { abs.listOfBackupFiles.forEach(f => { if (f.status !== BackupFileInfoStatus.Restored) { files.push(f); diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts deleted file mode 100644 index a9d684488c..0000000000 --- a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts +++ /dev/null @@ -1,593 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as azdata from 'azdata'; -import * as vscode from 'vscode'; -import { IconPathHelper } from '../../constants/iconPathHelper'; -import { getCurrentMigrations, getSelectedServiceStatus, MigrationLocalStorage, MigrationStatus } from '../../models/migrationLocalStorage'; -import { MigrationCutoverDialog } from '../migrationCutover/migrationCutoverDialog'; -import { AdsMigrationStatus, MigrationStatusDialogModel } from './migrationStatusDialogModel'; -import * as loc from '../../constants/strings'; -import { clearDialogMessage, convertTimeDifferenceToDuration, displayDialogErrorMessage, filterMigrations, getMigrationStatusImage } from '../../api/utils'; -import { SqlMigrationServiceDetailsDialog } from '../sqlMigrationService/sqlMigrationServiceDetailsDialog'; -import { ConfirmCutoverDialog } from '../migrationCutover/confirmCutoverDialog'; -import { MigrationCutoverDialogModel } from '../migrationCutover/migrationCutoverDialogModel'; -import { getMigrationTargetType, getMigrationMode, canRetryMigration } from '../../constants/helper'; -import { RetryMigrationDialog } from '../retryMigration/retryMigrationDialog'; -import { DatabaseMigration, getResourceName } from '../../api/azure'; -import { logError, TelemetryViews } from '../../telemtery'; -import { SelectMigrationServiceDialog } from '../selectMigrationService/selectMigrationServiceDialog'; - -const MenuCommands = { - Cutover: 'sqlmigration.cutover', - ViewDatabase: 'sqlmigration.view.database', - ViewTarget: 'sqlmigration.view.target', - ViewService: 'sqlmigration.view.service', - CopyMigration: 'sqlmigration.copy.migration', - CancelMigration: 'sqlmigration.cancel.migration', - RetryMigration: 'sqlmigration.retry.migration', -}; - -export class MigrationStatusDialog { - private _context: vscode.ExtensionContext; - private _model: MigrationStatusDialogModel; - private _dialogObject!: azdata.window.Dialog; - private _view!: azdata.ModelView; - private _searchBox!: azdata.InputBoxComponent; - private _refresh!: azdata.ButtonComponent; - private _serviceContextButton!: azdata.ButtonComponent; - private _statusDropdown!: azdata.DropDownComponent; - private _statusTable!: azdata.TableComponent; - private _refreshLoader!: azdata.LoadingComponent; - private _disposables: vscode.Disposable[] = []; - private _filteredMigrations: DatabaseMigration[] = []; - - private isRefreshing = false; - - constructor( - context: vscode.ExtensionContext, - private _filter: AdsMigrationStatus, - private _onClosedCallback: () => Promise) { - - this._context = context; - this._model = new MigrationStatusDialogModel([]); - this._dialogObject = azdata.window.createModelViewDialog( - loc.MIGRATION_STATUS, - 'MigrationControllerDialog', - 'wide'); - } - - async initialize() { - let tab = azdata.window.createTab(''); - tab.registerContent(async (view: azdata.ModelView) => { - this._view = view; - this.registerCommands(); - const form = view.modelBuilder.formContainer() - .withFormItems( - [ - { component: await this.createSearchAndRefreshContainer() }, - { component: this.createStatusTable() } - ], - { horizontal: false } - ).withLayout({ width: '100%' }) - .component(); - this._disposables.push( - this._view.onClosed(async e => { - this._disposables.forEach( - d => { try { d.dispose(); } catch { } }); - - await this._onClosedCallback(); - })); - - await view.initializeModel(form); - return await this.refreshTable(); - }); - this._dialogObject.content = [tab]; - this._dialogObject.cancelButton.hidden = true; - this._dialogObject.okButton.label = loc.CLOSE; - azdata.window.openDialog(this._dialogObject); - } - - private canCancelMigration = (status: string | undefined) => status && - ( - status === MigrationStatus.InProgress || - status === MigrationStatus.Creating || - status === MigrationStatus.Completing || - status === MigrationStatus.Canceling - ); - - private canCutoverMigration = (status: string | undefined) => status === MigrationStatus.InProgress; - - private async createSearchAndRefreshContainer(): Promise { - this._searchBox = this._view.modelBuilder.inputBox() - .withProps({ - stopEnterPropagation: true, - placeHolder: loc.SEARCH_FOR_MIGRATIONS, - width: '360px' - }).component(); - this._disposables.push( - this._searchBox.onTextChanged( - async (value) => await this.populateMigrationTable())); - - this._refresh = this._view.modelBuilder.button() - .withProps({ - iconPath: IconPathHelper.refresh, - iconHeight: '16px', - iconWidth: '20px', - label: loc.REFRESH_BUTTON_LABEL, - }).component(); - this._disposables.push( - this._refresh.onDidClick( - async (e) => await this.refreshTable())); - - this._statusDropdown = this._view.modelBuilder.dropDown() - .withProps({ - ariaLabel: loc.MIGRATION_STATUS_FILTER, - values: this._model.statusDropdownValues, - width: '220px' - }).component(); - this._disposables.push( - this._statusDropdown.onValueChanged( - async (value) => await this.populateMigrationTable())); - - if (this._filter) { - this._statusDropdown.value = - (this._statusDropdown.values) - .find(value => value.name === this._filter); - } - - this._refreshLoader = this._view.modelBuilder.loadingComponent() - .withProps({ loading: false }) - .component(); - - const searchLabel = this._view.modelBuilder.text() - .withProps({ - value: 'Status', - CSSStyles: { - 'font-size': '13px', - 'font-weight': '600', - 'margin': '3px 0 0 0', - }, - }).component(); - - const serviceContextLabel = await getSelectedServiceStatus(); - this._serviceContextButton = this._view.modelBuilder.button() - .withProps({ - iconPath: IconPathHelper.sqlMigrationService, - iconHeight: 22, - iconWidth: 22, - label: serviceContextLabel, - title: serviceContextLabel, - description: loc.MIGRATION_SERVICE_DESCRIPTION, - buttonType: azdata.ButtonType.Informational, - width: 270, - }).component(); - - const onDialogClosed = async (): Promise => { - const label = await getSelectedServiceStatus(); - this._serviceContextButton.label = label; - this._serviceContextButton.title = label; - await this.refreshTable(); - }; - - this._disposables.push( - this._serviceContextButton.onDidClick( - async () => { - const dialog = new SelectMigrationServiceDialog(onDialogClosed); - await dialog.initialize(); - })); - - const flexContainer = this._view.modelBuilder.flexContainer() - .withProps({ - width: '100%', - CSSStyles: { - 'justify-content': 'left', - 'align-items': 'center', - 'padding': '0px', - 'display': 'flex', - 'flex-direction': 'row', - }, - }).component(); - - flexContainer.addItem(this._searchBox, { flex: '0' }); - flexContainer.addItem(this._serviceContextButton, { flex: '0', CSSStyles: { 'margin-left': '20px' } }); - flexContainer.addItem(searchLabel, { flex: '0', CSSStyles: { 'margin-left': '20px' } }); - flexContainer.addItem(this._statusDropdown, { flex: '0', CSSStyles: { 'margin-left': '5px' } }); - flexContainer.addItem(this._refresh, { flex: '0', CSSStyles: { 'margin-left': '20px' } }); - flexContainer.addItem(this._refreshLoader, { flex: '0 0 auto', CSSStyles: { 'margin-left': '20px' } }); - - const container = this._view.modelBuilder.flexContainer() - .withProps({ width: 1245 }) - .component(); - container.addItem(flexContainer, { flex: '0 0 auto', }); - return container; - } - - private registerCommands(): void { - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.Cutover, - async (migrationId: string) => { - try { - clearDialogMessage(this._dialogObject); - const migration = this._model._migrations.find( - migration => migration.id === migrationId); - - if (this.canCutoverMigration(migration?.properties.migrationStatus)) { - const cutoverDialogModel = new MigrationCutoverDialogModel( - await MigrationLocalStorage.getMigrationServiceContext(), - migration!); - await cutoverDialogModel.fetchStatus(); - const dialog = new ConfirmCutoverDialog(cutoverDialogModel); - await dialog.initialize(); - if (cutoverDialogModel.CutoverError) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_CUTOVER_ERROR, cutoverDialogModel.CutoverError); - } - } else { - await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CUTOVER); - } - } catch (e) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_CUTOVER_ERROR, e); - console.log(e); - } - })); - - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.ViewDatabase, - async (migrationId: string) => { - try { - const migration = this._model._migrations.find(migration => migration.id === migrationId); - const dialog = new MigrationCutoverDialog( - this._context, - await MigrationLocalStorage.getMigrationServiceContext(), - migration!, - this._onClosedCallback); - await dialog.initialize(); - } catch (e) { - console.log(e); - } - })); - - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.ViewTarget, - async (migrationId: string) => { - try { - const migration = this._model._migrations.find(migration => migration.id === migrationId); - const url = 'https://portal.azure.com/#resource/' + migration!.properties.scope; - await vscode.env.openExternal(vscode.Uri.parse(url)); - } catch (e) { - console.log(e); - } - })); - - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.ViewService, - async (migrationId: string) => { - try { - const migration = this._model._migrations.find(migration => migration.id === migrationId); - const dialog = new SqlMigrationServiceDetailsDialog( - await MigrationLocalStorage.getMigrationServiceContext(), - migration!); - await dialog.initialize(); - } catch (e) { - console.log(e); - } - })); - - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.CopyMigration, - async (migrationId: string) => { - try { - clearDialogMessage(this._dialogObject); - const migration = this._model._migrations.find(migration => migration.id === migrationId); - const cutoverDialogModel = new MigrationCutoverDialogModel( - await MigrationLocalStorage.getMigrationServiceContext(), - migration!); - await cutoverDialogModel.fetchStatus(); - await vscode.env.clipboard.writeText(JSON.stringify(cutoverDialogModel.migrationStatus, undefined, 2)); - - await vscode.window.showInformationMessage(loc.DETAILS_COPIED); - } catch (e) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_STATUS_REFRESH_ERROR, e); - console.log(e); - } - })); - - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.CancelMigration, - async (migrationId: string) => { - try { - clearDialogMessage(this._dialogObject); - const migration = this._model._migrations.find(migration => migration.id === migrationId); - if (this.canCancelMigration(migration?.properties.migrationStatus)) { - void vscode.window.showInformationMessage(loc.CANCEL_MIGRATION_CONFIRMATION, loc.YES, loc.NO).then(async (v) => { - if (v === loc.YES) { - const cutoverDialogModel = new MigrationCutoverDialogModel( - await MigrationLocalStorage.getMigrationServiceContext(), - migration!); - await cutoverDialogModel.fetchStatus(); - await cutoverDialogModel.cancelMigration(); - - if (cutoverDialogModel.CancelMigrationError) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_CANNOT_CANCEL, cutoverDialogModel.CancelMigrationError); - } - } - }); - } else { - await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_CANCEL); - } - } catch (e) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_CANCELLATION_ERROR, e); - console.log(e); - } - })); - - this._disposables.push(vscode.commands.registerCommand( - MenuCommands.RetryMigration, - async (migrationId: string) => { - try { - clearDialogMessage(this._dialogObject); - const migration = this._model._migrations.find(migration => migration.id === migrationId); - if (canRetryMigration(migration?.properties.migrationStatus)) { - let retryMigrationDialog = new RetryMigrationDialog( - this._context, - await MigrationLocalStorage.getMigrationServiceContext(), - migration!, - this._onClosedCallback); - await retryMigrationDialog.openDialog(); - } - else { - await vscode.window.showInformationMessage(loc.MIGRATION_CANNOT_RETRY); - } - } catch (e) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_RETRY_ERROR, e); - console.log(e); - } - })); - } - - private async populateMigrationTable(): Promise { - try { - this._filteredMigrations = filterMigrations( - this._model._migrations, - (this._statusDropdown.value).name, - this._searchBox.value!); - - this._filteredMigrations.sort((m1, m2) => { - if (!m1.properties?.startedOn) { - return 1; - } else if (!m2.properties?.startedOn) { - return -1; - } - return new Date(m1.properties?.startedOn) > new Date(m2.properties?.startedOn) ? -1 : 1; - }); - - const data: any[] = this._filteredMigrations.map((migration, index) => { - return [ - { - icon: IconPathHelper.sqlDatabaseLogo, - title: migration.properties.sourceDatabaseName ?? '-', - }, // database - { - icon: getMigrationStatusImage(migration.properties.migrationStatus), - title: this._getMigrationStatus(migration), - }, // statue - getMigrationMode(migration), // mode - getMigrationTargetType(migration), // targetType - getResourceName(migration.id), // targetName - getResourceName(migration.properties.migrationService), // migrationService - this._getMigrationDuration( - migration.properties.startedOn, - migration.properties.endedOn), // duration - this._getMigrationTime(migration.properties.startedOn), // startTime - this._getMigrationTime(migration.properties.endedOn), // endTime - ]; - }); - - await this._statusTable.updateProperty('data', data); - } catch (e) { - logError(TelemetryViews.MigrationStatusDialog, 'Error populating migrations list page', e); - } - } - - private _getMigrationTime(migrationTime: string): string { - return migrationTime - ? new Date(migrationTime).toLocaleString() - : '---'; - } - - private _getMigrationDuration(startDate: string, endDate: string): string { - if (startDate) { - if (endDate) { - return convertTimeDifferenceToDuration( - new Date(startDate), - new Date(endDate)); - } else { - return convertTimeDifferenceToDuration( - new Date(startDate), - new Date()); - } - } - - return '---'; - } - - private _getMigrationStatus(migration: DatabaseMigration): string { - const properties = migration.properties; - const migrationStatus = properties.migrationStatus ?? properties.provisioningState; - let warningCount = 0; - - if (properties.migrationFailureError?.message) { - warningCount++; - } - if (properties.migrationStatusDetails?.fileUploadBlockingErrors) { - warningCount += properties.migrationStatusDetails?.fileUploadBlockingErrors.length; - } - if (properties.migrationStatusDetails?.restoreBlockingReason) { - warningCount++; - } - - return loc.STATUS_VALUE(migrationStatus, warningCount) + (loc.STATUS_WARNING_COUNT(migrationStatus, warningCount) ?? ''); - } - - public openCalloutDialog(dialogHeading: string, dialogName?: string, calloutMessageText?: string): void { - const dialog = azdata.window.createModelViewDialog(dialogHeading, dialogName, 288, 'callout', 'left', true, false, { - xPos: 0, - yPos: 0, - width: 20, - height: 20 - }); - const tab: azdata.window.DialogTab = azdata.window.createTab(''); - tab.registerContent(async view => { - const warningContentContainer = view.modelBuilder.divContainer().component(); - const messageTextComponent = view.modelBuilder.text().withProps({ - value: calloutMessageText, - CSSStyles: { - 'font-size': '12px', - 'line-height': '16px', - 'margin': '0 0 12px 0', - 'display': '-webkit-box', - '-webkit-box-orient': 'vertical', - '-webkit-line-clamp': '5', - 'overflow': 'hidden' - } - }).component(); - warningContentContainer.addItem(messageTextComponent); - - await view.initializeModel(warningContentContainer); - }); - - dialog.content = [tab]; - - azdata.window.openDialog(dialog); - } - - private async refreshTable(): Promise { - if (this.isRefreshing) { - return; - } - - this.isRefreshing = true; - try { - clearDialogMessage(this._dialogObject); - this._refreshLoader.loading = true; - this._model._migrations = await getCurrentMigrations(); - await this.populateMigrationTable(); - } catch (e) { - displayDialogErrorMessage(this._dialogObject, loc.MIGRATION_STATUS_REFRESH_ERROR, e); - console.log(e); - } finally { - this.isRefreshing = false; - this._refreshLoader.loading = false; - } - } - - private createStatusTable(): azdata.TableComponent { - const headerCssStyles = undefined; - const rowCssStyles = undefined; - - this._statusTable = this._view.modelBuilder.table().withProps({ - ariaLabel: loc.MIGRATION_STATUS, - data: [], - forceFitColumns: azdata.ColumnSizingMode.ForceFit, - height: '600px', - width: '1095px', - display: 'grid', - columns: [ - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.DATABASE, - value: 'database', - width: 190, - type: azdata.ColumnType.hyperlink, - icon: IconPathHelper.sqlDatabaseLogo, - showText: true, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.STATUS_COLUMN, - value: 'status', - width: 120, - type: azdata.ColumnType.hyperlink, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.MIGRATION_MODE, - value: 'mode', - width: 85, - type: azdata.ColumnType.text, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.AZURE_SQL_TARGET, - value: 'targetType', - width: 120, - type: azdata.ColumnType.text, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.TARGET_AZURE_SQL_INSTANCE_NAME, - value: 'targetName', - width: 125, - type: azdata.ColumnType.text, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.DATABASE_MIGRATION_SERVICE, - value: 'migrationService', - width: 140, - type: azdata.ColumnType.text, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.DURATION, - value: 'duration', - width: 50, - type: azdata.ColumnType.text, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.START_TIME, - value: 'startTime', - width: 115, - type: azdata.ColumnType.text, - }, - { - cssClass: rowCssStyles, - headerCssClass: headerCssStyles, - name: loc.FINISH_TIME, - value: 'finishTime', - width: 115, - type: azdata.ColumnType.text, - }, - ] - }).component(); - - this._disposables.push(this._statusTable.onCellAction!(async (rowState: azdata.ICellActionEventArgs) => { - const buttonState = rowState; - switch (buttonState?.column) { - case 0: - case 1: - const migration = this._filteredMigrations[rowState.row]; - const dialog = new MigrationCutoverDialog( - this._context, - await MigrationLocalStorage.getMigrationServiceContext(), - migration, - this._onClosedCallback); - await dialog.initialize(); - break; - } - })); - - return this._statusTable; - } -} diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialogModel.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialogModel.ts deleted file mode 100644 index f01e971254..0000000000 --- a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialogModel.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { DatabaseMigration } from '../../api/azure'; -import * as loc from '../../constants/strings'; - -export class MigrationStatusDialogModel { - public statusDropdownValues: azdata.CategoryValue[] = [ - { - displayName: loc.STATUS_ALL, - name: AdsMigrationStatus.ALL - }, { - displayName: loc.STATUS_ONGOING, - name: AdsMigrationStatus.ONGOING - }, { - displayName: loc.STATUS_COMPLETING, - name: AdsMigrationStatus.COMPLETING - }, { - displayName: loc.STATUS_SUCCEEDED, - name: AdsMigrationStatus.SUCCEEDED - }, { - displayName: loc.STATUS_FAILED, - name: AdsMigrationStatus.FAILED - } - ]; - - constructor(public _migrations: DatabaseMigration[]) { - } - -} - -/** - * This enum is used to categorize migrations internally in ADS. A migration has 2 statuses: Provisioning Status and Migration Status. The values from both the statuses are mapped to different values in this enum - */ -export enum AdsMigrationStatus { - ALL = 'all', - ONGOING = 'ongoing', - SUCCEEDED = 'succeeded', - FAILED = 'failed', - COMPLETING = 'completing' -} diff --git a/extensions/sql-migration/src/main.ts b/extensions/sql-migration/src/main.ts index b7475d5b18..59b8242dc5 100644 --- a/extensions/sql-migration/src/main.ts +++ b/extensions/sql-migration/src/main.ts @@ -79,7 +79,7 @@ class SQLMigration { }), azdata.tasks.registerTask( 'sqlmigration.refreshmigrations', - async (e) => await widget?.refreshMigrations()), + async (e) => await widget.refresh()), ]; this.context.subscriptions.push(...commandDisposables); diff --git a/extensions/sql-migration/src/models/migrationLocalStorage.ts b/extensions/sql-migration/src/models/migrationLocalStorage.ts index f05c938002..b763cee84b 100644 --- a/extensions/sql-migration/src/models/migrationLocalStorage.ts +++ b/extensions/sql-migration/src/models/migrationLocalStorage.ts @@ -95,7 +95,8 @@ export enum MigrationStatus { Canceled = 'Canceled', Completing = 'Completing', Creating = 'Creating', - Canceling = 'Canceling' + Canceling = 'Canceling', + Retriable = 'Retriable', } export enum ProvisioningState { @@ -110,6 +111,6 @@ export enum BackupFileInfoStatus { Uploaded = 'Uploaded', Restoring = 'Restoring', Restored = 'Restored', - Canceled = 'Canceled', + Cancelled = 'Cancelled', Ignored = 'Ignored' } diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 6ce7879474..d1491b3ad3 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -34,6 +34,11 @@ export enum State { EXIT, } +export enum ServiceTier { + GeneralPurpose = 'GeneralPurpose', + BusinessCritical = 'BusinessCritical', +} + export enum MigrationTargetType { SQLVM = 'AzureSqlVirtualMachine', SQLMI = 'AzureSqlManagedInstance', diff --git a/extensions/sql-migration/src/telemtery.ts b/extensions/sql-migration/src/telemtery.ts index 4e75520810..35d73abac9 100644 --- a/extensions/sql-migration/src/telemtery.ts +++ b/extensions/sql-migration/src/telemtery.ts @@ -21,6 +21,8 @@ export enum TelemetryViews { IntegrationRuntimePage = 'IntegrationRuntimePage', MigrationCutoverDialog = 'MigrationCutoverDialog', MigrationStatusDialog = 'MigrationStatusDialog', + DashboardTab = 'DashboardTab', + MigrationsTab = 'MigrationsTab', MigrationWizardAccountSelectionPage = 'MigrationWizardAccountSelectionPage', MigrationWizardSkuRecommendationPage = 'MigrationWizardSkuRecommendationPage', MigrationWizardTargetSelectionPage = 'MigrationWizardTargetSelectionPage',