diff --git a/extensions/sql-migration/images/migrationService.svg b/extensions/sql-migration/images/migrationService.svg new file mode 100644 index 0000000000..fd3b819e43 --- /dev/null +++ b/extensions/sql-migration/images/migrationService.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-migration/src/api/azure.ts b/extensions/sql-migration/src/api/azure.ts index 831d49ffd7..b613df7c44 100644 --- a/extensions/sql-migration/src/api/azure.ts +++ b/extensions/sql-migration/src/api/azure.ts @@ -175,6 +175,24 @@ export async function getSqlMigrationServiceAuthKeys(account: azdata.Account, su }; } +export async function regenerateSqlMigrationServiceAuthKey(account: azdata.Account, subscription: Subscription, resourceGroupName: string, regionName: string, sqlMigrationServiceName: string, keyName: string): Promise { + const api = await getAzureCoreAPI(); + const path = `/subscriptions/${subscription.id}/resourceGroups/${resourceGroupName}/providers/Microsoft.DataMigration/sqlMigrationServices/${sqlMigrationServiceName}/regenerateAuthKeys?api-version=2020-09-01-preview`; + const requestBody = { + 'location': regionName, + 'keyName': keyName, + }; + + const response = await api.makeAzureRestRequest(account, subscription, path, azurecore.HttpRequestMethod.POST, requestBody, true); + if (response.errors.length > 0) { + throw new Error(response.errors.toString()); + } + return { + authKey1: response?.response?.data?.authKey1 ?? '', + authKey2: response?.response?.data?.authKey2 ?? '' + }; +} + export async function getStorageAccountAccessKeys(account: azdata.Account, subscription: Subscription, storageAccount: StorageAccount): Promise { const api = await getAzureCoreAPI(); const path = `/subscriptions/${subscription.id}/resourceGroups/${storageAccount.resourceGroup}/providers/Microsoft.Storage/storageAccounts/${storageAccount.name}/listKeys?api-version=2019-06-01`; diff --git a/extensions/sql-migration/src/constants/iconPathHelper.ts b/extensions/sql-migration/src/constants/iconPathHelper.ts index c0c62a58e3..53cd081cdd 100644 --- a/extensions/sql-migration/src/constants/iconPathHelper.ts +++ b/extensions/sql-migration/src/constants/iconPathHelper.ts @@ -32,6 +32,7 @@ export class IconPathHelper { public static info: IconPath; public static error: IconPath; public static completingCutover: IconPath; + public static migrationService: IconPath; public static setExtensionContext(context: vscode.ExtensionContext) { IconPathHelper.copy = { @@ -118,5 +119,9 @@ export class IconPathHelper { light: context.asAbsolutePath('images/completingCutover.svg'), dark: context.asAbsolutePath('images/completingCutover.svg') }; + IconPathHelper.migrationService = { + light: context.asAbsolutePath('images/migrationService.svg'), + dark: context.asAbsolutePath('images/migrationService.svg') + }; } } diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 1de7080822..0bb430a9ef 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -190,6 +190,9 @@ export const SERVICE_KEY_COPIED_HELP = localize('sql.migration.key.copied', "Key export const REFRESH_KEYS = localize('sql.migration.refresh.keys', "Refresh keys"); export const COPY_KEY = localize('sql.migration.copy.key', "Copy key"); export const AUTH_KEY_COLUMN_HEADER = localize('sql.migration.authkeys.header', "Authentication key"); +export function AUTH_KEY_REFRESHED(keyName: string): string { + return localize('sql.migration.authkeys.refresh.message', "Authentication key '{0}' has been refreshed.", keyName); +} export function SERVICE_NOT_READY(serviceName: string): string { return localize('sql.migration.service.not.ready', "Azure Database Migration Service is not registered. Azure Database Migration Service '{0}' needs to be registered with self-hosted Integration Runtime on any node.", serviceName); } @@ -389,6 +392,14 @@ export function SEC(sec: number): string { return localize('sql.migration.sec', "{0} sec", sec); } +// 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"); +export const SQL_MIGRATION_SERVICE_DETAILS_IR_LABEL = localize('sql.migration.service.details.ir.label', "Self-hosted Integration Runtime node"); +export const SQL_MIGRATION_SERVICE_DETAILS_AUTH_KEYS_LABEL = localize('sql.migration.service.details.authkeys.label', "Authentication keys"); +export const SQL_MIGRATION_SERVICE_DETAILS_AUTH_KEYS_TITLE = localize('sql.migration.service.details.authkeys.title', "Authentication keys used to connect to the Self-hosted Integration Runtime node"); +export const SQL_MIGRATION_SERVICE_DETAILS_STATUS_UNAVAILABLE = localize('sql.migration.service.details.status.unavailable', "-- unavailable --"); + //Source Credentials page. export const SOURCE_CONFIGURATION = localize('sql.migration.source.configuration', "Source Configuration"); export const SOURCE_CREDENTIALS = localize('sql.migration.source.credentials', "Source Credentials"); diff --git a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts index b90960ae12..28cc48c7d8 100644 --- a/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts +++ b/extensions/sql-migration/src/dialog/migrationStatus/migrationStatusDialog.ts @@ -11,6 +11,8 @@ import { MigrationCutoverDialog } from '../migrationCutover/migrationCutoverDial import { AdsMigrationStatus, MigrationStatusDialogModel } from './migrationStatusDialogModel'; import * as loc from '../../constants/strings'; import { convertTimeDifferenceToDuration, filterMigrations } from '../../api/utils'; +import { SqlMigrationServiceDetailsDialog } from '../sqlMigrationService/sqlMigrationServiceDetailsDialog'; + export class MigrationStatusDialog { private _model: MigrationStatusDialogModel; private _dialogObject!: azdata.window.Dialog; @@ -171,7 +173,7 @@ export class MigrationStatusDialog { url: '' }).component(); dms.onDidClick((e) => { - vscode.window.showInformationMessage(loc.COMING_SOON); + (new SqlMigrationServiceDetailsDialog(migration)).initialize(); }); migrationRow.push({ diff --git a/extensions/sql-migration/src/dialog/sqlMigrationService/sqlMigrationServiceDetailsDialog.ts b/extensions/sql-migration/src/dialog/sqlMigrationService/sqlMigrationServiceDetailsDialog.ts new file mode 100644 index 0000000000..255b01bcaa --- /dev/null +++ b/extensions/sql-migration/src/dialog/sqlMigrationService/sqlMigrationServiceDetailsDialog.ts @@ -0,0 +1,305 @@ +// /*--------------------------------------------------------------------------------------------- +// * 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 { getSqlMigrationServiceAuthKeys, getSqlMigrationServiceMonitoringData, regenerateSqlMigrationServiceAuthKey } from '../../api/azure'; +import { IconPathHelper } from '../../constants/iconPathHelper'; +import * as constants from '../../constants/strings'; +import { MigrationContext } from '../../models/migrationLocalStorage'; + +const CONTROL_MARGIN = '10px'; +const COLUMN_WIDTH = '50px'; +const STRETCH_WIDTH = '100%'; +const LABEL_MARGIN = '0 10px 0 10px'; +const VALUE_MARGIN = '0 10px 10px 10px'; +const INFO_VALUE_MARGIN = '0 10px 0 0'; +const NO_MARGIN = '0'; +const ICON_SIZE = '28px'; +const IMAGE_SIZE = '21px'; +const TITLE_FONT_SIZE = '14px'; +const DESCRIPTION_FONT_SIZE = '10px'; +const FONT_SIZE = '13px'; +const FONT_WEIGHT_BOLD = 'bold'; +const AUTH_KEY1 = 'authKey1'; +const AUTH_KEY2 = 'authKey2'; + +export class SqlMigrationServiceDetailsDialog { + + private _dialog: azdata.window.Dialog; + private _migrationServiceAuthKeyTable!: azdata.DeclarativeTableComponent; + + constructor(private migrationContext: MigrationContext) { + this._dialog = azdata.window.createModelViewDialog( + '', + 'SqlMigrationServiceDetailsDialog', + 580, + 'flyout'); + } + + async initialize(): Promise { + this._dialog.registerContent( + async (view: azdata.ModelView) => await this.createServiceContent( + view, + this.migrationContext)); + + this._dialog.okButton.label = constants.SQL_MIGRATION_SERVICE_DETAILS_BUTTON_LABEL; + this._dialog.okButton.focused = true; + this._dialog.cancelButton.hidden = true; + azdata.window.openDialog(this._dialog); + } + + private async createServiceContent(view: azdata.ModelView, migrationContext: MigrationContext): Promise { + this._migrationServiceAuthKeyTable = this._createIrTable(view); + const serviceNode = (await getSqlMigrationServiceMonitoringData( + migrationContext.azureAccount, + migrationContext.subscription, + migrationContext.controller.properties.resourceGroup, + migrationContext.controller.location, + migrationContext.controller.name + )); + const serviceNodeName = serviceNode.nodes?.map(node => node.nodeName).join(', ') + || constants.SQL_MIGRATION_SERVICE_DETAILS_STATUS_UNAVAILABLE; + + const flexContainer = view.modelBuilder + .flexContainer() + .withItems([ + this._createHeading(view, migrationContext), + view.modelBuilder + .separator() + .withProps({ width: STRETCH_WIDTH }) + .component(), + this._createTextItem(view, constants.SUBSCRIPTION, LABEL_MARGIN), + this._createTextItem(view, migrationContext.subscription.name, VALUE_MARGIN), + this._createTextItem(view, constants.LOCATION, LABEL_MARGIN), + this._createTextItem(view, migrationContext.controller.location.toUpperCase(), VALUE_MARGIN), + this._createTextItem(view, constants.RESOURCE_GROUP, LABEL_MARGIN), + this._createTextItem(view, migrationContext.controller.properties.resourceGroup, VALUE_MARGIN), + this._createTextItem(view, constants.SQL_MIGRATION_SERVICE_DETAILS_IR_LABEL, LABEL_MARGIN), + this._createTextItem(view, serviceNodeName, VALUE_MARGIN), + this._createTextItem( + view, + constants.SQL_MIGRATION_SERVICE_DETAILS_AUTH_KEYS_LABEL, + INFO_VALUE_MARGIN, + constants.SQL_MIGRATION_SERVICE_DETAILS_AUTH_KEYS_TITLE), + this._migrationServiceAuthKeyTable, + ]) + .withLayout({ flexFlow: 'column' }) + .withProps({ CSSStyles: { 'padding': CONTROL_MARGIN } }) + .component(); + + await view.initializeModel(flexContainer); + return await this._refreshAuthTable(view, migrationContext); + } + + private _createHeading(view: azdata.ModelView, migrationContext: MigrationContext): azdata.FlexContainer { + return view.modelBuilder + .flexContainer() + .withItems([ + view.modelBuilder + .image() + .withProps({ + iconPath: IconPathHelper.migrationService, + iconHeight: ICON_SIZE, + iconWidth: ICON_SIZE, + height: ICON_SIZE, + width: ICON_SIZE, + }) + .withProps({ CSSStyles: { 'margin-right': CONTROL_MARGIN } }) + .component(), + view.modelBuilder + .flexContainer() + .withItems([ + view.modelBuilder + .text() + .withProps({ + value: migrationContext.controller.name, + CSSStyles: { + 'font-size': TITLE_FONT_SIZE, + 'font-weight': FONT_WEIGHT_BOLD, + 'margin': NO_MARGIN, + } + }) + .component(), + view.modelBuilder + .text() + .withProps({ + value: constants.SQL_MIGRATION_SERVICE_DETAILS_SUB_TITLE, + CSSStyles: { + 'font-size': DESCRIPTION_FONT_SIZE, + 'margin': NO_MARGIN, + } + }) + .component(), + ]) + .withLayout({ + flexFlow: 'column', + textAlign: 'left', + }) + .component(), + ]) + .withLayout({ flexFlow: 'row' }) + .withProps({ + display: 'inline-flex', + CSSStyles: { 'margin': LABEL_MARGIN }, + }) + .component(); + } + + private _createTextItem(view: azdata.ModelView, value: string, margin: string, description?: string): azdata.TextComponent { + return view.modelBuilder + .text() + .withProps({ + value: value, + description: description, + title: value, + CSSStyles: { + 'font-size': FONT_SIZE, + 'margin': margin, + } + }) + .component(); + } + + private _createIrTable(view: azdata.ModelView): azdata.DeclarativeTableComponent { + return view.modelBuilder + .declarativeTable() + .withProps({ + columns: [ + this._createColumn(constants.NAME, COLUMN_WIDTH, azdata.DeclarativeDataType.string), + this._createColumn(constants.AUTH_KEY_COLUMN_HEADER, STRETCH_WIDTH, azdata.DeclarativeDataType.string), + this._createColumn('', COLUMN_WIDTH, azdata.DeclarativeDataType.component), + ], + CSSStyles: { + 'margin': VALUE_MARGIN, + 'text-align': 'left', + }, + }) + .component(); + } + + private _createColumn(name: string, width: string, valueType: azdata.DeclarativeDataType): azdata.DeclarativeTableColumn { + return { + displayName: name, + valueType: valueType, + width: width, + isReadOnly: true, + rowCssStyles: { 'font-size': FONT_SIZE }, + headerCssStyles: { + 'font-size': FONT_SIZE, + 'font-weight': 'normal', + }, + }; + } + + private async _regenerateAuthKey(view: azdata.ModelView, migrationContext: MigrationContext, keyName: string): Promise { + const keys = await regenerateSqlMigrationServiceAuthKey( + migrationContext.azureAccount, + migrationContext.subscription, + migrationContext.controller.properties.resourceGroup, + migrationContext.controller.location.toUpperCase(), + migrationContext.controller.name, + keyName); + + if (keys?.authKey1 && keyName === AUTH_KEY1) { + await this._updateTableCell(this._migrationServiceAuthKeyTable, 0, 1, keys.authKey1, constants.SERVICE_KEY1_LABEL); + } + else if (keys?.authKey2 && keyName === AUTH_KEY2) { + await this._updateTableCell(this._migrationServiceAuthKeyTable, 1, 1, keys.authKey2, constants.SERVICE_KEY2_LABEL); + } + } + + private async _updateTableCell(table: azdata.DeclarativeTableComponent, row: number, col: number, value: string, keyName: string): Promise { + const dataValues = table.dataValues; + dataValues![row][col].value = value; + table.dataValues = []; + table.dataValues = dataValues; + await vscode.window.showInformationMessage(constants.AUTH_KEY_REFRESHED(keyName)); + } + + private async _refreshAuthTable(view: azdata.ModelView, migrationContext: MigrationContext): Promise { + const keys = await getSqlMigrationServiceAuthKeys( + migrationContext.azureAccount, + migrationContext.subscription, + migrationContext.controller.properties.resourceGroup, + migrationContext.controller.location.toUpperCase(), + migrationContext.controller.name); + + const copyKey1Button = view.modelBuilder + .button() + .withProps({ + iconPath: IconPathHelper.copy, + height: IMAGE_SIZE, + width: IMAGE_SIZE, + }) + .component(); + + copyKey1Button.onDidClick((e) => { + vscode.env.clipboard.writeText(keys.authKey1); + vscode.window.showInformationMessage(constants.SERVICE_KEY_COPIED_HELP); + }); + + const copyKey2Button = view.modelBuilder + .button() + .withProps({ + iconPath: IconPathHelper.copy, + height: IMAGE_SIZE, + width: IMAGE_SIZE, + }) + .component(); + + copyKey2Button.onDidClick((e) => { + vscode.env.clipboard.writeText(keys.authKey2); + vscode.window.showInformationMessage(constants.SERVICE_KEY_COPIED_HELP); + }); + + const refreshKey1Button = view.modelBuilder + .button() + .withProps({ + iconPath: IconPathHelper.refresh, + height: IMAGE_SIZE, + width: IMAGE_SIZE, + }) + .component(); + refreshKey1Button.onDidClick( + async (e) => await this._regenerateAuthKey(view, migrationContext, AUTH_KEY1)); + + const refreshKey2Button = view.modelBuilder + .button() + .withProps({ + iconPath: IconPathHelper.refresh, + height: IMAGE_SIZE, + width: IMAGE_SIZE, + }) + .component(); + refreshKey2Button.onDidClick( + async (e) => await this._regenerateAuthKey(view, migrationContext, AUTH_KEY2)); + + this._migrationServiceAuthKeyTable.updateProperties({ + dataValues: [ + [ + { value: constants.SERVICE_KEY1_LABEL }, + { value: keys.authKey1 }, + { + value: view.modelBuilder + .flexContainer() + .withItems([copyKey1Button, refreshKey1Button]) + .component() + } + ], + [ + { value: constants.SERVICE_KEY2_LABEL }, + { value: keys.authKey2 }, + { + value: view.modelBuilder + .flexContainer() + .withItems([copyKey2Button, refreshKey2Button]) + .component() + } + ] + ] + }); + } +}