diff --git a/extensions/mssql/src/mssql.d.ts b/extensions/mssql/src/mssql.d.ts index 83bfec55d8..53b2e1f17b 100644 --- a/extensions/mssql/src/mssql.d.ts +++ b/extensions/mssql/src/mssql.d.ts @@ -541,6 +541,7 @@ declare module 'mssql' { sqlMiRecommendationResults: PaaSSkuRecommendationResultItem[]; sqlVmRecommendationResults: IaaSSkuRecommendationResultItem[]; instanceRequirements: SqlInstanceRequirements; + skuRecommendationReportPaths: string[]; } // SKU recommendation enums, mirrored from Microsoft.SqlServer.Migration.SkuRecommendation @@ -785,6 +786,7 @@ declare module 'mssql' { assessmentResult: ServerAssessmentProperties; rawAssessmentResult: any; errors: ErrorModel[]; + assessmentReportPath: string; } export interface ISqlMigrationService { diff --git a/extensions/sql-migration/src/api/utils.ts b/extensions/sql-migration/src/api/utils.ts index b7cf8826b4..890e83183a 100644 --- a/extensions/sql-migration/src/api/utils.ts +++ b/extensions/sql-migration/src/api/utils.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { window, Account, accounts, CategoryValue, DropDownComponent, IconPath } from 'azdata'; +import * as vscode from 'vscode'; import { IconPathHelper } from '../constants/iconPathHelper'; import { MigrationStatus, ProvisioningState } from '../models/migrationLocalStorage'; import * as crypto from 'crypto'; @@ -865,3 +866,21 @@ export async function getBlobLastBackupFileNamesValues(lastFileNames: azureResou } return lastFileNamesValues; } + +export async function promptUserForFolder(): Promise { + let path = ''; + + let options: vscode.OpenDialogOptions = { + defaultUri: vscode.Uri.file(getUserHome()!), + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }; + + let fileUris = await vscode.window.showOpenDialog(options); + if (fileUris && fileUris?.length > 0 && fileUris[0]) { + path = fileUris[0].fsPath; + } + + return path; +} diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 3d759edd9d..2e29a35b61 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -94,6 +94,14 @@ export function CAN_BE_MIGRATED(eligibleDbs: number, totalDbs: number): string { export const ASSESSMENT_MIGRATION_WARNING = localize('sql.migration.assessment.migration.warning', "Databases that are not ready for migration to Azure SQL Managed Instance can be migrated to SQL Server on Azure Virtual Machines."); export const DATABASES_TABLE_TILE = localize('sql.migration.databases.table.title', "Databases"); export const SQL_SERVER_INSTANCE = localize('sql.migration.sql.server.instance', "SQL Server instance"); +export const SAVE_ASSESSMENT_REPORT = localize('sql.migration.save.assessment.report', "Save assessment report"); +export const SAVE_RECOMMENDATION_REPORT = localize('sql.migration.save.recommendation.report', "Save recommendation report"); +export function SAVE_ASSESSMENT_REPORT_SUCCESS(filePath: string): string { + return localize('sql.migration.save.assessment.report.success', "Successfully saved assessment report to {0}.", filePath); +} +export function SAVE_RECOMMENDATION_REPORT_SUCCESS(filePath: string): string { + return localize('sql.migration.save.recommendation.report.success', "Successfully saved recommendation report to {0}.", filePath); +} // SKU export const AZURE_RECOMMENDATION = localize('sql.migration.sku.recommendation', "Azure recommendation"); diff --git a/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts b/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts index 13d6a889b4..e70f2aa661 100644 --- a/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts +++ b/extensions/sql-migration/src/dialog/assessmentResults/assessmentResultsDialog.ts @@ -9,6 +9,10 @@ import { MigrationStateModel, MigrationTargetType } from '../../models/stateMach import { SqlDatabaseTree } from './sqlDatabasesTree'; import { SqlMigrationImpactedObjectInfo } from 'mssql'; import { SKURecommendationPage } from '../../wizard/skuRecommendationPage'; +import * as constants from '../../constants/strings'; +import * as utils from '../../api/utils'; +import * as fs from 'fs'; +import path = require('path'); export type Issues = { description: string, @@ -24,6 +28,8 @@ export class AssessmentResultsDialog { private _isOpen: boolean = false; private dialog: azdata.window.Dialog | undefined; private _model: MigrationStateModel; + private _saveButton!: azdata.window.Button; + private static readonly _assessmentReportName: string = 'SqlAssessmentReport.json'; // Dialog Name for Telemetry public dialogName: string | undefined; @@ -71,6 +77,27 @@ export class AssessmentResultsDialog { this.dialog.cancelButton.label = AssessmentResultsDialog.CancelButtonText; this._disposables.push(this.dialog.cancelButton.onClick(async () => await this.cancel())); + this._saveButton = azdata.window.createButton( + constants.SAVE_ASSESSMENT_REPORT, + 'left'); + this._disposables.push( + this._saveButton.onClick(async () => { + const folder = await utils.promptUserForFolder(); + const destinationFilePath = path.join(folder, AssessmentResultsDialog._assessmentReportName); + if (this.model._assessmentReportFilePath) { + fs.copyFile(this.model._assessmentReportFilePath, destinationFilePath, (err) => { + if (err) { + console.log(err); + } else { + void vscode.window.showInformationMessage(constants.SAVE_ASSESSMENT_REPORT_SUCCESS(destinationFilePath)); + } + }); + } else { + console.log('assessment report not found'); + } + })); + this.dialog.customButtons = [this._saveButton]; + const dialogSetupPromises: Thenable[] = []; dialogSetupPromises.push(this.initializeDialog(this.dialog)); diff --git a/extensions/sql-migration/src/dialog/skuRecommendationResults/getAzureRecommendationDialog.ts b/extensions/sql-migration/src/dialog/skuRecommendationResults/getAzureRecommendationDialog.ts index 8aad372b44..5e26f8996e 100644 --- a/extensions/sql-migration/src/dialog/skuRecommendationResults/getAzureRecommendationDialog.ts +++ b/extensions/sql-migration/src/dialog/skuRecommendationResults/getAzureRecommendationDialog.ts @@ -197,7 +197,7 @@ export class GetAzureRecommendationDialog { } }).component(); this._disposables.push(browseButton.onDidClick(async (e) => { - let folder = await this.handleBrowse(); + let folder = await utils.promptUserForFolder(); this._collectDataFolderInput.value = folder; })); @@ -259,7 +259,7 @@ export class GetAzureRecommendationDialog { } }).component(); this._disposables.push(openButton.onDidClick(async (e) => { - let folder = await this.handleBrowse(); + let folder = await utils.promptUserForFolder(); this._openExistingFolderInput.value = folder; })); @@ -380,23 +380,4 @@ export class GetAzureRecommendationDialog { public get isOpen(): boolean { return this._isOpen; } - - // TO-DO: add validation - private async handleBrowse(): Promise { - let path = ''; - - let options: vscode.OpenDialogOptions = { - defaultUri: vscode.Uri.file(utils.getUserHome()!), - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - }; - - let fileUris = await vscode.window.showOpenDialog(options); - if (fileUris && fileUris?.length > 0 && fileUris[0]) { - path = fileUris[0].fsPath; - } - - return path; - } } diff --git a/extensions/sql-migration/src/dialog/skuRecommendationResults/skuRecommendationResultsDialog.ts b/extensions/sql-migration/src/dialog/skuRecommendationResults/skuRecommendationResultsDialog.ts index f4d4e86efd..5abdd2fe3c 100644 --- a/extensions/sql-migration/src/dialog/skuRecommendationResults/skuRecommendationResultsDialog.ts +++ b/extensions/sql-migration/src/dialog/skuRecommendationResults/skuRecommendationResultsDialog.ts @@ -9,6 +9,9 @@ import { MigrationStateModel, MigrationTargetType } from '../../models/stateMach import * as constants from '../../constants/strings'; import * as styles from '../../constants/styles'; import * as mssql from 'mssql'; +import * as utils from '../../api/utils'; +import * as fs from 'fs'; +import path = require('path'); export class SkuRecommendationResultsDialog { @@ -23,6 +26,7 @@ export class SkuRecommendationResultsDialog { private _disposables: vscode.Disposable[] = []; public title?: string; public targetName?: string; + private _saveButton!: azdata.window.Button; public targetRecommendations?: mssql.SkuRecommendationResultItem[]; public instanceRequirements?: mssql.SqlInstanceRequirements; @@ -491,6 +495,48 @@ export class SkuRecommendationResultsDialog { // this.dialog.cancelButton.label = SkuRecommendationResultsDialog.CreateTargetButtonText; // this._disposables.push(this.dialog.cancelButton.onClick(async () => console.log(SkuRecommendationResultsDialog.CreateTargetButtonText))); + this._saveButton = azdata.window.createButton( + constants.SAVE_RECOMMENDATION_REPORT, + 'left'); + this._disposables.push( + this._saveButton.onClick(async () => { + const folder = await utils.promptUserForFolder(); + + if (this.model._skuRecommendationReportFilePaths) { + + let sourceFilePath: string | undefined; + let destinationFilePath: string | undefined; + + switch (this._targetType) { + case MigrationTargetType.SQLMI: + sourceFilePath = this.model._skuRecommendationReportFilePaths.find(filePath => filePath.includes('SkuRecommendationReport-AzureSqlManagedInstance')); + destinationFilePath = path.join(folder, 'SkuRecommendationReport-AzureSqlManagedInstance.html'); + break; + + case MigrationTargetType.SQLVM: + sourceFilePath = this.model._skuRecommendationReportFilePaths.find(filePath => filePath.includes('SkuRecommendationReport-AzureSqlVirtualMachine')); + destinationFilePath = path.join(folder, 'SkuRecommendationReport-AzureSqlVirtualMachine.html'); + break; + + case MigrationTargetType.SQLDB: + sourceFilePath = this.model._skuRecommendationReportFilePaths.find(filePath => filePath.includes('SkuRecommendationReport-AzureSqlDatabase')); + destinationFilePath = path.join(folder, 'SkuRecommendationReport-AzureSqlDatabase.html'); + break; + } + + fs.copyFile(sourceFilePath!, destinationFilePath, (err) => { + if (err) { + console.log(err); + } else { + void vscode.window.showInformationMessage(constants.SAVE_RECOMMENDATION_REPORT_SUCCESS(destinationFilePath!)); + } + }); + } else { + console.log('recommendation report not found'); + } + })); + this.dialog.customButtons = [this._saveButton]; + const dialogSetupPromises: Thenable[] = []; dialogSetupPromises.push(this.initializeDialog(this.dialog)); azdata.window.openDialog(this.dialog); diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index d1491b3ad3..4d06153eb1 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -199,6 +199,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _assessedDatabaseList!: string[]; public _runAssessments: boolean = true; private _assessmentApiResponse!: mssql.AssessmentResult; + public _assessmentReportFilePath: string; public mementoString: string; public _databasesForMigration: string[] = []; @@ -210,6 +211,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _skuRecommendationResults!: SkuRecommendation; public _skuRecommendationPerformanceDataSource!: PerformanceDataSourceOptions; private _skuRecommendationApiResponse!: mssql.SkuRecommendationResult; + public _skuRecommendationReportFilePaths: string[]; public _skuRecommendationPerformanceLocation!: string; private _skuRecommendationRecommendedDatabaseList!: string[]; private _startPerfDataCollectionApiResponse!: mssql.StartPerfDataCollectionResult; @@ -263,6 +265,8 @@ export class MigrationStateModel implements Model, vscode.Disposable { this._databaseBackup.networkShares = []; this._databaseBackup.blobs = []; this._targetDatabaseNames = []; + this._assessmentReportFilePath = ''; + this._skuRecommendationReportFilePaths = []; this.mementoString = 'sqlMigration.assessmentResults'; this._skuScalingFactor = 100; @@ -325,6 +329,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { }) ?? [], errors: this._assessmentApiResponse?.errors ?? [] }; + this._assessmentReportFilePath = response.assessmentReportPath; } else { this._assessmentResults = { issues: [], @@ -394,16 +399,19 @@ export class MigrationStateModel implements Model, vscode.Disposable { sqlDbRecommendationResults: response?.sqlDbRecommendationResults ?? [], sqlMiRecommendationResults: response?.sqlMiRecommendationResults ?? [], sqlVmRecommendationResults: response?.sqlVmRecommendationResults ?? [], - instanceRequirements: response?.instanceRequirements + instanceRequirements: response?.instanceRequirements, + skuRecommendationReportPaths: response?.skuRecommendationReportPaths }, }; + this._skuRecommendationReportFilePaths = response.skuRecommendationReportPaths; } else { this._skuRecommendationResults = { recommendations: { sqlDbRecommendationResults: [], sqlMiRecommendationResults: [], sqlVmRecommendationResults: [], - instanceRequirements: response?.instanceRequirements + instanceRequirements: response?.instanceRequirements, + skuRecommendationReportPaths: response?.skuRecommendationReportPaths }, }; } @@ -416,7 +424,8 @@ export class MigrationStateModel implements Model, vscode.Disposable { sqlDbRecommendationResults: this._skuRecommendationApiResponse?.sqlDbRecommendationResults ?? [], sqlMiRecommendationResults: this._skuRecommendationApiResponse?.sqlMiRecommendationResults ?? [], sqlVmRecommendationResults: this._skuRecommendationApiResponse?.sqlVmRecommendationResults ?? [], - instanceRequirements: this._skuRecommendationApiResponse?.instanceRequirements + instanceRequirements: this._skuRecommendationApiResponse?.instanceRequirements, + skuRecommendationReportPaths: this._skuRecommendationApiResponse?.skuRecommendationReportPaths }, recommendationError: error };