diff --git a/extensions/mssql/src/errorDiagnostics/errorDiagnosticsConstants.ts b/extensions/mssql/src/errorDiagnostics/errorDiagnosticsConstants.ts new file mode 100644 index 0000000000..a569dc49b6 --- /dev/null +++ b/extensions/mssql/src/errorDiagnostics/errorDiagnosticsConstants.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Error code reference comes from here: https://learn.microsoft.com/en-us/sql/relational-databases/errors-events/database-engine-events-and-errors?view=sql-server-ver16 +export const MssqlPasswordResetErrorCode: number = 18488; diff --git a/extensions/mssql/src/errorDiagnostics/errorDiagnosticsProvider.ts b/extensions/mssql/src/errorDiagnostics/errorDiagnosticsProvider.ts new file mode 100644 index 0000000000..99bd306873 --- /dev/null +++ b/extensions/mssql/src/errorDiagnostics/errorDiagnosticsProvider.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ISqlOpsFeature, SqlOpsDataClient, SqlOpsFeature } from 'dataprotocol-client'; +import * as UUID from 'vscode-languageclient/lib/utils/uuid'; +import { AppContext } from '../appContext'; +import { ServerCapabilities, ClientCapabilities, RPCMessageType } from 'vscode-languageclient'; +import { Disposable } from 'vscode'; +import * as CoreConstants from '../constants'; +import * as ErrorDiagnosticsConstants from './errorDiagnosticsConstants'; +import { logDebug } from '../utils'; + +export class ErrorDiagnosticsProvider extends SqlOpsFeature { + private static readonly messagesTypes: RPCMessageType[] = []; + + public static asFeature(context: AppContext): ISqlOpsFeature { + return class extends ErrorDiagnosticsProvider { + constructor(client: SqlOpsDataClient) { + super(context, client); + } + + override fillClientCapabilities(capabilities: ClientCapabilities): void { } + + override initialize(): void { + this.register(this.messages, { + id: UUID.generateUuid(), + registerOptions: undefined + }); + } + + private convertToIConnectionProfile(profile: azdata.connection.ConnectionProfile): azdata.IConnectionProfile { + return { + providerName: profile.providerId, + id: profile.connectionId, + connectionName: profile.connectionName, + serverName: profile.serverName, + databaseName: profile.databaseName, + userName: profile.userName, + password: profile.password, + authenticationType: profile.authenticationType, + savePassword: profile.savePassword, + groupFullName: profile.groupFullName, + groupId: profile.groupId, + saveProfile: profile.savePassword, + azureTenantId: profile.azureTenantId, + options: profile.options + }; + } + + protected override registerProvider(options: any): Disposable { + let handleConnectionError = async (errorCode: number, errorMessage: string, connection: azdata.connection.ConnectionProfile): Promise => { + let restoredProfile = this.convertToIConnectionProfile(connection); + if (errorCode === ErrorDiagnosticsConstants.MssqlPasswordResetErrorCode) { + logDebug(`Error Code ${errorCode} requires user to change their password, launching change password dialog.`) + return await this.handleChangePassword(restoredProfile); + } + logDebug(`No error handler found for errorCode ${errorCode}.`); + return { handled: false }; + } + + return azdata.diagnostics.registerDiagnosticsProvider({ + targetProviderId: CoreConstants.providerId, + }, { + handleConnectionError + }); + } + + private async handleChangePassword(connection: azdata.IConnectionProfile): Promise { + try { + const result = await azdata.connection.openChangePasswordDialog(connection); + // result will be undefined if password change was closed or cancelled. + if (result) { + // MSSQL uses 'password' as the option key for connection profile. + connection.options['password'] = result; + return { handled: true, options: connection.options }; + } + } + catch (e) { + console.error(`Change password failed unexpectedly with error: ${e}`); + } + return { handled: false }; + } + } + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { } + + initialize(capabilities: ServerCapabilities): void { } + + private constructor(context: AppContext, protected readonly client: SqlOpsDataClient) { + super(client, ErrorDiagnosticsProvider.messagesTypes); + } + + protected registerProvider(options: any): Disposable { return undefined; } +} diff --git a/extensions/mssql/src/sqlToolsServer.ts b/extensions/mssql/src/sqlToolsServer.ts index 9fab32e842..f2eeb84e1c 100644 --- a/extensions/mssql/src/sqlToolsServer.ts +++ b/extensions/mssql/src/sqlToolsServer.ts @@ -27,6 +27,7 @@ import { NotebookConvertService } from './notebookConvert/notebookConvertService import { SqlMigrationService } from './sqlMigration/sqlMigrationService'; import { SqlCredentialService } from './credentialstore/sqlCredentialService'; import { AzureBlobService } from './azureBlob/azureBlobService'; +import { ErrorDiagnosticsProvider } from './errorDiagnostics/errorDiagnosticsProvider'; import { TdeMigrationService } from './tdeMigration/tdeMigrationService'; const localize = nls.loadMessageBundle(); @@ -194,7 +195,8 @@ function getClientOptions(context: AppContext): ClientOptions { SqlCredentialService.asFeature(context), TableDesignerFeature, ExecutionPlanServiceFeature, - TdeMigrationService.asFeature(context), + ErrorDiagnosticsProvider.asFeature(context), + TdeMigrationService.asFeature(context) ], outputChannel: outputChannel }; diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index f0edaeefed..c840b29c1a 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -432,6 +432,54 @@ declare module 'azdata' { azurePortalEndpoint?: string; } + + export namespace diagnostics { + /** + * Represents a diagnostics provider of accounts. + */ + export interface ErrorDiagnosticsProviderMetadata { + /** + * The id of the provider (ex. a connection provider) that a diagnostics provider will handle errors for. + * Note: only ONE diagnostic provider per id/name at a time. + */ + targetProviderId: string; + } + + export interface ConnectionDiagnosticsResult { + /** + * Status indicating if the error was handled or not. + */ + handled: boolean, + /** + * If given, the new set of connection options to assign to the original connection profile, overwriting any previous options. + */ + options?: { [name: string]: any }; + } + + /** + * Diagnostics object for handling errors for a provider. + */ + export interface ErrorDiagnosticsProvider { + /** + * Called when a connection error occurs, allowing the provider to optionally handle the error and fix any issues before continuing with completing the connection. + * @param errorCode The error code of the connection error. + * @param errorMessage The error message of the connection error. + * @param connection The connection profile that caused the error. + * @returns ConnectionDiagnosticsResult: The result from the provider for whether the error was handled. + */ + handleConnectionError(errorCode: number, errorMessage: string, connection: connection.ConnectionProfile): Thenable; + } + + /** + * Registers provider with instance of Diagnostic Provider implementation. + * Note: only ONE diagnostic provider object can be assigned to a specific provider at a time. + * @param providerMetadata Additional data used to register the provider + * @param errorDiagnostics The provider's diagnostic object that handles errors. + * @returns A disposable that when disposed will unregister the provider + */ + export function registerDiagnosticsProvider(providerMetadata: ErrorDiagnosticsProviderMetadata, errorDiagnostics: ErrorDiagnosticsProvider): vscode.Disposable; + } + export namespace connection { /** * Well-known Authentication types commonly supported by connection providers. @@ -462,6 +510,13 @@ declare module 'azdata' { */ None = 'None' } + + /** + * Opens the change password dialog. + * @param profile The connection profile to change the password for. + * @returns The new password that is returned from the operation or undefined if unsuccessful. + */ + export function openChangePasswordDialog(profile: IConnectionProfile): Thenable; } /* diff --git a/src/sql/platform/connection/common/connectionManagement.ts b/src/sql/platform/connection/common/connectionManagement.ts index fb4a80f6a5..72ed8c2fac 100644 --- a/src/sql/platform/connection/common/connectionManagement.ts +++ b/src/sql/platform/connection/common/connectionManagement.ts @@ -333,6 +333,13 @@ export interface IConnectionManagementService { * @returns Promise with a boolean value indicating whether the user has accepted the suggestion. */ handleUnsupportedProvider(providerId: string): Promise; + + /** + * Launches the password change dialog. + * @param profile The connection profile to change the password. + * @returns the new valid password that is entered, or undefined if cancelled or errored. + */ + openChangePasswordDialog(profile: IConnectionProfile): Promise; } export enum RunQueryOnConnectionMode { diff --git a/src/sql/platform/connection/common/constants.ts b/src/sql/platform/connection/common/constants.ts index 8319360866..9251bfffb2 100644 --- a/src/sql/platform/connection/common/constants.ts +++ b/src/sql/platform/connection/common/constants.ts @@ -65,6 +65,3 @@ export const UNSAVED_GROUP_ID = 'unsaved'; /* Server Type Constants */ export const sqlDataWarehouse = 'Azure SQL Data Warehouse'; export const gen3Version = 12; - -/* SQL Server Password Reset Error Code */ -export const sqlPasswordErrorCode = 18488; diff --git a/src/sql/platform/connection/common/utils.ts b/src/sql/platform/connection/common/utils.ts index ee38ec8978..7002f2f494 100644 --- a/src/sql/platform/connection/common/utils.ts +++ b/src/sql/platform/connection/common/utils.ts @@ -6,6 +6,8 @@ import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; import { ConnectionProfileGroup } from 'sql/platform/connection/common/connectionProfileGroup'; +import { deepClone } from 'vs/base/common/objects'; +import * as sqlExtHostTypes from 'sql/workbench/api/common/sqlExtHostTypes' // CONSTANTS ////////////////////////////////////////////////////////////////////////////////////// const msInH = 3.6e6; @@ -137,3 +139,42 @@ export function isServerConnection(profile: IConnectionProfile): boolean { // If the user did not specify a database in the original connection, then this is considered a server-level connection return !profile.options.originalDatabase; } + +/** + * Convert a IConnectionProfile with services to an azdata.connection.ConnectionProfile + * shaped object that can be sent via RPC. + * @param profile The profile to be converted. + * @param deepCopyOptions whether to deep copy the options or not. + * @param removeFunction the function that strips the credentials from the connection profile if provided. + * @returns An azdata.connection.ConnectionProfile shaped object that contains only the data and none of the services. + */ +export function convertToRpcConnectionProfile(profile: IConnectionProfile | undefined, deepCopyOptions: boolean = false, removeFunction?: (profile: IConnectionProfile) => IConnectionProfile): sqlExtHostTypes.ConnectionProfile | undefined { + if (!profile) { + return undefined; + } + + // If provided, that means the connection profile must be stripped of credentials. + if (removeFunction) { + profile = removeFunction(profile); + } + + let connection: sqlExtHostTypes.ConnectionProfile = { + providerId: profile.providerName, + connectionId: profile.id, + options: deepCopyOptions ? deepClone(profile.options) : profile.options, + connectionName: profile.connectionName, + serverName: profile.serverName, + databaseName: profile.databaseName, + userName: profile.userName, + password: profile.password, + authenticationType: profile.authenticationType, + savePassword: profile.savePassword, + groupFullName: profile.groupFullName, + groupId: profile.groupId, + saveProfile: profile.saveProfile, + azureTenantId: profile.azureTenantId, + azureAccount: profile.azureAccount + } + + return connection; +} diff --git a/src/sql/platform/connection/test/common/testConnectionManagementService.ts b/src/sql/platform/connection/test/common/testConnectionManagementService.ts index bf22ed8cca..9b3bff1dd2 100644 --- a/src/sql/platform/connection/test/common/testConnectionManagementService.ts +++ b/src/sql/platform/connection/test/common/testConnectionManagementService.ts @@ -330,4 +330,8 @@ export class TestConnectionManagementService implements IConnectionManagementSer async handleUnsupportedProvider(providerName: string): Promise { return true; } + + openChangePasswordDialog(profile: IConnectionProfile): Promise { + return undefined; + } } diff --git a/src/sql/workbench/api/browser/extensionHost.contribution.ts b/src/sql/workbench/api/browser/extensionHost.contribution.ts index 94cfe6b7d0..ecaae84193 100644 --- a/src/sql/workbench/api/browser/extensionHost.contribution.ts +++ b/src/sql/workbench/api/browser/extensionHost.contribution.ts @@ -20,5 +20,6 @@ import './mainThreadNotebookDocumentsAndEditors'; import './mainThreadObjectExplorer'; import './mainThreadQueryEditor'; import './mainThreadResourceProvider'; +import './mainThreadErrorDiagnostics'; import './mainThreadTasks'; import './mainThreadWorkspace'; diff --git a/src/sql/workbench/api/browser/mainThreadConnectionManagement.ts b/src/sql/workbench/api/browser/mainThreadConnectionManagement.ts index 2206315018..9809af3eb1 100644 --- a/src/sql/workbench/api/browser/mainThreadConnectionManagement.ts +++ b/src/sql/workbench/api/browser/mainThreadConnectionManagement.ts @@ -10,6 +10,7 @@ import { IObjectExplorerService } from 'sql/workbench/services/objectExplorer/br import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import * as TaskUtilities from 'sql/workbench/browser/taskUtilities'; import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; +import { convertToRpcConnectionProfile } from 'sql/platform/connection/common/utils'; import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { isUndefinedOrNull } from 'vs/base/common/types'; import { generateUuid } from 'vs/base/common/uuid'; @@ -92,7 +93,7 @@ export class MainThreadConnectionManagement extends Disposable implements MainTh } public $getConnections(activeConnectionsOnly?: boolean): Thenable { - return Promise.resolve(this._connectionManagementService.getConnections(activeConnectionsOnly).map(profile => this.convertToConnectionProfile(profile))); + return Promise.resolve(this._connectionManagementService.getConnections(activeConnectionsOnly).map(profile => convertToRpcConnectionProfile(profile, true, this._connectionManagementService.removeConnectionProfileCredentials))); } public $getConnection(uri: string): Thenable { @@ -101,22 +102,7 @@ export class MainThreadConnectionManagement extends Disposable implements MainTh return Promise.resolve(undefined); } - let connection: azdata.connection.ConnectionProfile = { - providerId: profile.providerName, - connectionId: profile.id, - connectionName: profile.connectionName, - serverName: profile.serverName, - databaseName: profile.databaseName, - userName: profile.userName, - password: profile.password, - authenticationType: profile.authenticationType, - savePassword: profile.savePassword, - groupFullName: profile.groupFullName, - groupId: profile.groupId, - saveProfile: profile.savePassword, - azureTenantId: profile.azureTenantId, - options: profile.options - }; + let connection = convertToRpcConnectionProfile(profile); return Promise.resolve(connection); } @@ -129,7 +115,7 @@ export class MainThreadConnectionManagement extends Disposable implements MainTh } public $getCurrentConnectionProfile(): Thenable { - return Promise.resolve(this.convertToConnectionProfile(TaskUtilities.getCurrentGlobalConnection(this._objectExplorerService, this._connectionManagementService, this._workbenchEditorService, true))); + return Promise.resolve(convertToRpcConnectionProfile(TaskUtilities.getCurrentGlobalConnection(this._objectExplorerService, this._connectionManagementService, this._workbenchEditorService, true,), true, this._connectionManagementService.removeConnectionProfileCredentials)); } public $getCredentials(connectionId: string): Thenable<{ [name: string]: string }> { @@ -182,6 +168,12 @@ export class MainThreadConnectionManagement extends Disposable implements MainTh return connection; } + public $openChangePasswordDialog(profile: IConnectionProfile): Thenable { + // Need to have access to getOptionsKey, so recreate profile from details. + let convertedProfile = new ConnectionProfile(this._capabilitiesService, profile); + return this._connectionManagementService.openChangePasswordDialog(convertedProfile); + } + public async $listDatabases(connectionId: string): Promise { let connectionUri = await this.$getUriForConnection(connectionId); let result = await this._connectionManagementService.listDatabases(connectionUri); @@ -209,30 +201,6 @@ export class MainThreadConnectionManagement extends Disposable implements MainTh return connection; } - private convertToConnectionProfile(profile: IConnectionProfile): azdata.connection.ConnectionProfile { - if (!profile) { - return undefined; - } - - profile = this._connectionManagementService.removeConnectionProfileCredentials(profile); - let connection: azdata.connection.ConnectionProfile = { - providerId: profile.providerName, - connectionId: profile.id, - options: deepClone(profile.options), - connectionName: profile.connectionName, - serverName: profile.serverName, - databaseName: profile.databaseName, - userName: profile.userName, - password: profile.password, - authenticationType: profile.authenticationType, - savePassword: profile.savePassword, - groupFullName: profile.groupFullName, - groupId: profile.groupId, - saveProfile: profile.saveProfile - }; - return connection; - } - public $connect(connectionProfile: IConnectionProfile, saveConnection: boolean = true, showDashboard: boolean = true): Thenable { let profile = new ConnectionProfile(this._capabilitiesService, connectionProfile); profile.id = generateUuid(); diff --git a/src/sql/workbench/api/browser/mainThreadErrorDiagnostics.ts b/src/sql/workbench/api/browser/mainThreadErrorDiagnostics.ts new file mode 100644 index 0000000000..6b5cca64f1 --- /dev/null +++ b/src/sql/workbench/api/browser/mainThreadErrorDiagnostics.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IErrorDiagnosticsService } from 'sql/workbench/services/diagnostics/common/errorDiagnosticsService'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { + ExtHostErrorDiagnosticsShape, + MainThreadErrorDiagnosticsShape +} from 'sql/workbench/api/common/sqlExtHost.protocol'; +import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; +import { SqlExtHostContext, SqlMainContext } from 'vs/workbench/api/common/extHost.protocol'; + +@extHostNamedCustomer(SqlMainContext.MainThreadErrorDiagnostics) +export class MainThreadErrorDiagnostics extends Disposable implements MainThreadErrorDiagnosticsShape { + private _providerMetadata: { [handle: number]: azdata.diagnostics.ErrorDiagnosticsProviderMetadata }; + private _proxy: ExtHostErrorDiagnosticsShape; + + constructor( + extHostContext: IExtHostContext, + @IErrorDiagnosticsService private _errorDiagnosticsService: IErrorDiagnosticsService + ) { + super(); + this._providerMetadata = {}; + if (extHostContext) { + this._proxy = extHostContext.getProxy(SqlExtHostContext.ExtHostErrorDiagnostics); + } + } + + public $registerDiagnosticsProvider(providerMetadata: azdata.diagnostics.ErrorDiagnosticsProviderMetadata, handle: number): Thenable { + let self = this; + + //Create the error handler that interfaces with the extension via the proxy and register it + let errorDiagnostics: azdata.diagnostics.ErrorDiagnosticsProvider = { + handleConnectionError(errorCode: number, errorMessage: string, connection: azdata.connection.ConnectionProfile): Thenable { + return self._proxy.$handleConnectionError(handle, errorCode, errorMessage, connection); + } + }; + this._errorDiagnosticsService.registerDiagnosticsProvider(providerMetadata.targetProviderId, errorDiagnostics); + this._providerMetadata[handle] = providerMetadata; + return undefined; + } + + public $unregisterDiagnosticsProvider(handle: number): Thenable { + this._errorDiagnosticsService.unregisterDiagnosticsProvider(this._providerMetadata[handle].targetProviderId); + return undefined; + } +} diff --git a/src/sql/workbench/api/common/extHostConnectionManagement.ts b/src/sql/workbench/api/common/extHostConnectionManagement.ts index f0eea544bd..56f27dbc8f 100644 --- a/src/sql/workbench/api/common/extHostConnectionManagement.ts +++ b/src/sql/workbench/api/common/extHostConnectionManagement.ts @@ -74,6 +74,10 @@ export class ExtHostConnectionManagement extends ExtHostConnectionManagementShap return this._proxy.$openConnectionDialog(providers, initialConnectionProfile, connectionCompletionOptions); } + public $openChangePasswordDialog(profile: azdata.IConnectionProfile): Thenable { + return this._proxy.$openChangePasswordDialog(profile); + } + public $listDatabases(connectionId: string): Thenable { return this._proxy.$listDatabases(connectionId); } diff --git a/src/sql/workbench/api/common/extHostErrorDiagnostics.ts b/src/sql/workbench/api/common/extHostErrorDiagnostics.ts new file mode 100644 index 0000000000..5d71deb7f9 --- /dev/null +++ b/src/sql/workbench/api/common/extHostErrorDiagnostics.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IMainContext } from 'vs/workbench/api/common/extHost.protocol'; +import { Disposable } from 'vs/workbench/api/common/extHostTypes'; +import { + ExtHostErrorDiagnosticsShape, + MainThreadErrorDiagnosticsShape, +} from 'sql/workbench/api/common/sqlExtHost.protocol'; +import { values } from 'vs/base/common/collections'; +import { SqlMainContext } from 'vs/workbench/api/common/extHost.protocol'; + +export class ExtHostErrorDiagnostics extends ExtHostErrorDiagnosticsShape { + private _handlePool: number = 0; + private _proxy: MainThreadErrorDiagnosticsShape; + private _providers: { [handle: number]: DiagnosticsWithMetadata } = {}; + + constructor(mainContext: IMainContext) { + super(); + this._proxy = mainContext.getProxy(SqlMainContext.MainThreadErrorDiagnostics); + } + + // PUBLIC METHODS ////////////////////////////////////////////////////// + // - MAIN THREAD AVAILABLE METHODS ///////////////////////////////////// + public override $handleConnectionError(handle: number, errorCode: number, errorMessage: string, connection: azdata.connection.ConnectionProfile): Thenable { + let provider = this._providers[handle]; + if (provider === undefined) { + return Promise.resolve({ handled: false }); + } + else { + return provider.provider.handleConnectionError(errorCode, errorMessage, connection); + } + } + + // - EXTENSION HOST AVAILABLE METHODS ////////////////////////////////// + public $registerDiagnosticsProvider(providerMetadata: azdata.diagnostics.ErrorDiagnosticsProviderMetadata, errorDiagnostics: azdata.diagnostics.ErrorDiagnosticsProvider): Disposable { + let self = this; + + // Look for any diagnostic providers that have the same provider ID + let matchingProviderIndex = values(this._providers).findIndex((provider: DiagnosticsWithMetadata) => { + return provider.metadata.targetProviderId === providerMetadata.targetProviderId; + }); + if (matchingProviderIndex >= 0) { + throw new Error(`Diagnostics Provider with ID '${providerMetadata.targetProviderId}' has already been registered`); + } + + // Create the handle for the provider + let handle: number = this._nextHandle(); + this._providers[handle] = { + metadata: providerMetadata, + provider: errorDiagnostics + }; + + // Register the provider in the main thread via the proxy + this._proxy.$registerDiagnosticsProvider(providerMetadata, handle); + + // Return a disposable to cleanup the provider + return new Disposable(() => { + delete self._providers[handle]; + self._proxy.$unregisterDiagnosticsProvider(handle); + }); + } + + /** + * This method is for testing only, it is not exposed via the shape. + * @return Number of providers that are currently registered + */ + public getProviderCount(): number { + return Object.keys(this._providers).length; + } + + // PRIVATE METHODS ///////////////////////////////////////////////////// + private _nextHandle(): number { + return this._handlePool++; + } +} + +interface DiagnosticsWithMetadata { + metadata: azdata.diagnostics.ErrorDiagnosticsProviderMetadata; + provider: azdata.diagnostics.ErrorDiagnosticsProvider; +} diff --git a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts index f7ccfb33e5..00135f3802 100644 --- a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts @@ -10,6 +10,7 @@ import { ExtHostAccountManagement } from 'sql/workbench/api/common/extHostAccoun import { ExtHostCredentialManagement } from 'sql/workbench/api/common/extHostCredentialManagement'; import { ExtHostDataProtocol } from 'sql/workbench/api/common/extHostDataProtocol'; import { ExtHostResourceProvider } from 'sql/workbench/api/common/extHostResourceProvider'; +import { ExtHostErrorDiagnostics } from 'sql/workbench/api/common/extHostErrorDiagnostics'; import * as sqlExtHostTypes from 'sql/workbench/api/common/sqlExtHostTypes'; import { ExtHostModalDialogs } from 'sql/workbench/api/common/extHostModalDialog'; import { ExtHostTasks } from 'sql/workbench/api/common/extHostTasks'; @@ -87,6 +88,7 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp const extHostDataProvider = rpcProtocol.set(SqlExtHostContext.ExtHostDataProtocol, new ExtHostDataProtocol(rpcProtocol, uriTransformer)); const extHostObjectExplorer = rpcProtocol.set(SqlExtHostContext.ExtHostObjectExplorer, new ExtHostObjectExplorer(rpcProtocol, commands)); const extHostResourceProvider = rpcProtocol.set(SqlExtHostContext.ExtHostResourceProvider, new ExtHostResourceProvider(rpcProtocol)); + const extHostErrorDiagnostics = rpcProtocol.set(SqlExtHostContext.ExtHostErrorDiagnostics, new ExtHostErrorDiagnostics(rpcProtocol)); const extHostModalDialogs = rpcProtocol.set(SqlExtHostContext.ExtHostModalDialogs, new ExtHostModalDialogs(rpcProtocol)); const extHostTasks = rpcProtocol.set(SqlExtHostContext.ExtHostTasks, new ExtHostTasks(rpcProtocol, extHostLogService)); const extHostBackgroundTaskManagement = rpcProtocol.set(SqlExtHostContext.ExtHostBackgroundTaskManagement, new ExtHostBackgroundTaskManagement(rpcProtocol)); @@ -136,6 +138,9 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp openConnectionDialog(providers?: string[], initialConnectionProfile?: azdata.IConnectionProfile, connectionCompletionOptions?: azdata.IConnectionCompletionOptions): Thenable { return extHostConnectionManagement.$openConnectionDialog(providers, initialConnectionProfile, connectionCompletionOptions); }, + openChangePasswordDialog(profile: azdata.IConnectionProfile): Thenable { + return extHostConnectionManagement.$openChangePasswordDialog(profile); + }, listDatabases(connectionId: string): Thenable { return extHostConnectionManagement.$listDatabases(connectionId); }, @@ -215,6 +220,13 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp } }; + // namespace: diagnostics + const diagnostics: typeof azdata.diagnostics = { + registerDiagnosticsProvider: (providerMetadata: azdata.diagnostics.ErrorDiagnosticsProviderMetadata, errorDiagnostics: azdata.diagnostics.ErrorDiagnosticsProvider): vscode.Disposable => { + return extHostErrorDiagnostics.$registerDiagnosticsProvider(providerMetadata, errorDiagnostics); + } + } + let registerConnectionProvider = (provider: azdata.ConnectionProvider): vscode.Disposable => { // Connection callbacks provider.registerOnConnectionComplete((connSummary: azdata.ConnectionInfoSummary) => { @@ -666,6 +678,7 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp TextType: sqlExtHostTypes.TextType, designers: designers, executionPlan: executionPlan, + diagnostics: diagnostics, env }; } diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index 4604ed6131..257e99feb4 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -604,7 +604,16 @@ export abstract class ExtHostResourceProviderShape { * Handle firewall rule */ $handleFirewallRule(handle: number, errorCode: number, errorMessage: string, connectionTypeId: string): Thenable { throw ni(); } +} +/** + * ResourceProvider extension host class. + */ +export abstract class ExtHostErrorDiagnosticsShape { + /** + * Handle other connection error types + */ + $handleConnectionError(handle: number, errorCode: number, errorMessage: string, connection: azdata.connection.ConnectionProfile): Thenable { throw ni(); } } /** @@ -646,6 +655,11 @@ export interface MainThreadResourceProviderShape extends IDisposable { $unregisterResourceProvider(handle: number): Thenable; } +export interface MainThreadErrorDiagnosticsShape extends IDisposable { + $registerDiagnosticsProvider(providerMetadata: azdata.diagnostics.ErrorDiagnosticsProviderMetadata, handle: number): Thenable; + $unregisterDiagnosticsProvider(handle: number): Thenable; +} + export interface MainThreadDataProtocolShape extends IDisposable { $registerConnectionProvider(providerId: string, handle: number): Promise; $registerBackupProvider(providerId: string, handle: number): Promise; @@ -708,6 +722,7 @@ export interface MainThreadConnectionManagementShape extends IDisposable { $getCredentials(connectionId: string): Thenable<{ [name: string]: string }>; $getServerInfo(connectedId: string): Thenable; $openConnectionDialog(providers: string[], initialConnectionProfile?: azdata.IConnectionProfile, connectionCompletionOptions?: azdata.IConnectionCompletionOptions): Thenable; + $openChangePasswordDialog(profile: azdata.IConnectionProfile): Thenable; $listDatabases(connectionId: string): Thenable; $getConnectionString(connectionId: string, includePassword: boolean): Thenable; $getUriForConnection(connectionId: string): Thenable; diff --git a/src/sql/workbench/services/connection/browser/connectionDialogService.ts b/src/sql/workbench/services/connection/browser/connectionDialogService.ts index c887600851..f4050f9879 100644 --- a/src/sql/workbench/services/connection/browser/connectionDialogService.ts +++ b/src/sql/workbench/services/connection/browser/connectionDialogService.ts @@ -30,7 +30,6 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import { CmsConnectionController } from 'sql/workbench/services/connection/browser/cmsConnectionController'; -import { PasswordChangeDialog } from 'sql/workbench/services/connection/browser/passwordChangeDialog'; import { entries } from 'sql/base/common/collections'; import { onUnexpectedError } from 'vs/base/common/errors'; import { ILogService } from 'vs/platform/log/common/log'; @@ -284,9 +283,6 @@ export class ConnectionDialogService implements IConnectionDialogService { } else if (connectionResult && connectionResult.errorHandled) { this._connectionDialog.resetConnection(); this._logService.debug(`ConnectionDialogService: Error handled and connection reset - Error: ${connectionResult.errorMessage}`); - } else if (connection.providerName === Constants.mssqlProviderName && connectionResult.errorCode === Constants.sqlPasswordErrorCode) { - this._connectionDialog.resetConnection(); - this.launchChangePasswordDialog(connection, params); } else { this._connectionDialog.resetConnection(); this.showErrorDialog(Severity.Error, this._connectionErrorTitle, connectionResult.errorMessage, connectionResult.callStack, connectionResult.errorCode); @@ -507,11 +503,6 @@ export class ConnectionDialogService implements IConnectionDialogService { recentConnections.forEach(conn => conn.dispose()); } - public launchChangePasswordDialog(profile: IConnectionProfile, params: INewConnectionParams): void { - let dialog = this._instantiationService.createInstance(PasswordChangeDialog); - dialog.open(profile, params); - } - private showErrorDialog(severity: Severity, headerTitle: string, message: string, messageDetails?: string, errorCode?: number): void { // Kerberos errors are currently very hard to understand, so adding handling of these to solve the common scenario diff --git a/src/sql/workbench/services/connection/browser/connectionManagementService.ts b/src/sql/workbench/services/connection/browser/connectionManagementService.ts index 4a70c49a03..7cacf653a0 100644 --- a/src/sql/workbench/services/connection/browser/connectionManagementService.ts +++ b/src/sql/workbench/services/connection/browser/connectionManagementService.ts @@ -55,6 +55,8 @@ import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/b import { ViewContainerLocation } from 'vs/workbench/common/views'; import { VIEWLET_ID as ExtensionsViewletID } from 'vs/workbench/contrib/extensions/common/extensions'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IErrorDiagnosticsService } from 'sql/workbench/services/diagnostics/common/errorDiagnosticsService'; +import { PasswordChangeDialog } from 'sql/workbench/services/connection/browser/passwordChangeDialog'; export class ConnectionManagementService extends Disposable implements IConnectionManagementService { @@ -94,6 +96,7 @@ export class ConnectionManagementService extends Disposable implements IConnecti @IQuickInputService private _quickInputService: IQuickInputService, @INotificationService private _notificationService: INotificationService, @IResourceProviderService private _resourceProviderService: IResourceProviderService, + @IErrorDiagnosticsService private _errorDiagnosticsService: IErrorDiagnosticsService, @IAngularEventingService private _angularEventing: IAngularEventingService, @IAccountManagementService private _accountManagementService: IAccountManagementService, @ILogService private _logService: ILogService, @@ -430,7 +433,7 @@ export class ConnectionManagementService extends Disposable implements IConnecti /** * Changes password of the connection profile's user. */ - public changePassword(connection: interfaces.IConnectionProfile, uri: string, newPassword: string): + public async changePassword(connection: interfaces.IConnectionProfile, uri: string, newPassword: string): Promise { return this.sendChangePasswordRequest(connection, uri, newPassword); } @@ -552,20 +555,32 @@ export class ConnectionManagementService extends Disposable implements IConnecti }); } - private handleConnectionError(connection: interfaces.IConnectionProfile, uri: string, options: IConnectionCompletionOptions, callbacks: IConnectionCallbacks, connectionResult: IConnectionResult) { + private async handleConnectionError(connection: interfaces.IConnectionProfile, uri: string, options: IConnectionCompletionOptions, callbacks: IConnectionCallbacks, connectionResult: IConnectionResult) { let connectionNotAcceptedError = nls.localize('connectionNotAcceptedError', "Connection Not Accepted"); if (options.showFirewallRuleOnError && connectionResult.errorCode) { - return this.handleFirewallRuleError(connection, connectionResult).then(success => { - if (success) { - options.showFirewallRuleOnError = false; + let firewallRuleErrorHandled = await this.handleFirewallRuleError(connection, connectionResult); + if (firewallRuleErrorHandled) { + options.showFirewallRuleOnError = false; + return this.connectWithOptions(connection, uri, options, callbacks); + } + else { + let connectionErrorHandled = await this._errorDiagnosticsService.tryHandleConnectionError(connectionResult.errorCode, connectionResult.errorMessage, connection.providerName, connection); + if (connectionErrorHandled.handled) { + connectionResult.errorHandled = true; + if (connectionErrorHandled.options) { + //copy over altered connection options from the result if provided. + connection.options = connectionErrorHandled.options; + } return this.connectWithOptions(connection, uri, options, callbacks); - } else { + } + else { + // Error not handled by any registered providers so fail the connection if (callbacks.onConnectReject) { callbacks.onConnectReject(connectionNotAcceptedError); } return connectionResult; } - }); + } } else { if (callbacks.onConnectReject) { callbacks.onConnectReject(connectionNotAcceptedError); @@ -585,6 +600,12 @@ export class ConnectionManagementService extends Disposable implements IConnecti }); } + public async openChangePasswordDialog(profile: interfaces.IConnectionProfile): Promise { + let dialog = this._instantiationService.createInstance(PasswordChangeDialog); + let result = await dialog.open(profile); + return result; + } + private doActionsAfterConnectionComplete(uri: string, options: IConnectionCompletionOptions): void { let connectionManagementInfo = this._connectionStatusManager.findConnection(uri); if (!connectionManagementInfo) { diff --git a/src/sql/workbench/services/connection/browser/media/passwordDialog.css b/src/sql/workbench/services/connection/browser/media/passwordDialog.css index 86574a2f56..4115f7b940 100644 --- a/src/sql/workbench/services/connection/browser/media/passwordDialog.css +++ b/src/sql/workbench/services/connection/browser/media/passwordDialog.css @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ .change-password-dialog { - width: 100%; + padding: 30px; height: 100%; overflow: hidden; display: flex; @@ -15,8 +15,9 @@ overflow-y: auto; } -.change-password-dialog .component-label { - vertical-align: middle; +.change-password-dialog .component-label-bold { + font-weight: 600; + padding-bottom: 30px; } .change-password-dialog .components-grid { @@ -25,7 +26,7 @@ grid-template-columns: max-content 1fr; grid-template-rows: max-content; grid-gap: 10px; - padding: 5px; + padding: 30px 0px 10px; align-content: start; box-sizing: border-box; } diff --git a/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts b/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts index ea028097d2..3bc51e40e5 100644 --- a/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts +++ b/src/sql/workbench/services/connection/browser/passwordChangeDialog.ts @@ -8,7 +8,6 @@ import { Button } from 'sql/base/browser/ui/button/button'; import { Modal } from 'sql/workbench/browser/modal/modal'; import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; import { attachInputBoxStyler } from 'sql/platform/theme/common/styler'; -import { INewConnectionParams } from 'sql/platform/connection/common/connectionManagement'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; @@ -19,7 +18,6 @@ import { IContextViewService } from 'vs/platform/contextview/browser/contextView import * as DOM from 'vs/base/browser/dom'; import { ILogService } from 'vs/platform/log/common/log'; import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; -import { IConnectionDialogService } from 'sql/workbench/services/connection/common/connectionDialogService'; import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; import Severity from 'vs/base/common/severity'; import { IErrorMessageService } from 'sql/platform/errorMessage/common/errorMessageService'; @@ -27,12 +25,11 @@ import { attachModalDialogStyler } from 'sql/workbench/common/styler'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfiguration'; -const dialogWidth: string = '300px'; // Width is set manually here as there is no default width for normal dialogs. +const dialogWidth: string = '500px'; // Width is set manually here as there is no default width for normal dialogs. const okText: string = localize('passwordChangeDialog.ok', "OK"); const cancelText: string = localize('passwordChangeDialog.cancel', "Cancel"); -const dialogTitle: string = localize('passwordChangeDialog.title', "Change Password"); -const newPasswordText: string = localize('passwordChangeDialog.newPassword', 'New password:'); -const confirmPasswordText: string = localize('passwordChangeDialog.confirmPassword', 'Confirm password:'); +const newPasswordText: string = localize('passwordChangeDialog.newPassword', "New password:"); +const confirmPasswordText: string = localize('passwordChangeDialog.confirmPassword', "Confirm password:"); const passwordChangeLoadText: string = localize('passwordChangeDialog.connecting', "Connecting"); const errorHeader: string = localize('passwordChangeDialog.errorHeader', "Failure when attempting to change password"); const errorPasswordMismatchErrorMessage = localize('passwordChangeDialog.errorPasswordMismatchErrorMessage', "Passwords entered do not match"); @@ -42,8 +39,8 @@ export class PasswordChangeDialog extends Modal { private _okButton?: Button; private _cancelButton?: Button; + private _promiseResolver: (value: string) => void; private _profile: IConnectionProfile; - private _params: INewConnectionParams; private _uri: string; private _passwordValueText: InputBox; private _confirmValueText: InputBox; @@ -53,7 +50,6 @@ export class PasswordChangeDialog extends Modal { @IClipboardService clipboardService: IClipboardService, @IConnectionManagementService private readonly connectionManagementService: IConnectionManagementService, @IErrorMessageService private readonly errorMessageService: IErrorMessageService, - @IConnectionDialogService private readonly connectionDialogService: IConnectionDialogService, @ILayoutService layoutService: ILayoutService, @IAdsTelemetryService telemetryService: IAdsTelemetryService, @IContextKeyService contextKeyService: IContextKeyService, @@ -64,31 +60,43 @@ export class PasswordChangeDialog extends Modal { super('', '', telemetryService, layoutService, clipboardService, themeService, logService, textResourcePropertiesService, contextKeyService, { hasSpinner: true, spinnerTitle: passwordChangeLoadText, dialogStyle: 'normal', width: dialogWidth, dialogPosition: 'left' }); } - public open(profile: IConnectionProfile, params: INewConnectionParams) { + public open(profile: IConnectionProfile): Promise { + if (this._profile) { + // If already in the middle of a password change, reject an incoming open. + let message = localize('passwordChangeDialog.passwordChangeInProgress', "Password change already in progress") + this.errorMessageService.showDialog(Severity.Error, errorHeader, message); + return Promise.reject(new Error(message)); + } this._profile = profile; - this._params = params; this._uri = this.connectionManagementService.getConnectionUri(profile); this.render(); this.show(); this._okButton!.focus(); + const promise = new Promise((resolve) => { + this._promiseResolver = resolve; + }); + return promise; } - public override dispose(): void { - - } + public override dispose(): void { } public override render() { super.render(); - this.title = dialogTitle; + this.title = localize('passwordChangeDialog.title', 'Change Password'); this._register(attachModalDialogStyler(this, this._themeService)); - this._okButton = this.addFooterButton(okText, () => this.handleOkButtonClick()); - this._cancelButton = this.addFooterButton(cancelText, () => this.hide('cancel'), 'right', true); + this._okButton = this.addFooterButton(okText, async () => { await this.handleOkButtonClick(); }); + this._cancelButton = this.addFooterButton(cancelText, () => { this.handleCancelButtonClick(); }, 'right', true); this._register(attachButtonStyler(this._okButton, this._themeService)); this._register(attachButtonStyler(this._cancelButton, this._themeService)); } protected renderBody(container: HTMLElement) { const body = container.appendChild(DOM.$('.change-password-dialog')); + body.appendChild(DOM.$('span.component-label-bold')).innerText = localize('passwordChangeDialog.Message1', + `Password must be changed for '{0}' to continue logging into '{1}'.`, this._profile?.userName, this._profile?.serverName); + body.appendChild(DOM.$('span.component-label')).innerText = localize('passwordChangeDialog.Message2', + `Please enter a new password below:`); + const contentElement = body.appendChild(DOM.$('.properties-content.components-grid')); contentElement.appendChild(DOM.$('')).appendChild(DOM.$('span.component-label')).innerText = newPasswordText; const passwordInputContainer = contentElement.appendChild(DOM.$('')); @@ -107,42 +115,50 @@ export class PasswordChangeDialog extends Modal { /* espace key */ protected override onClose() { - this.hide('close'); + this.handleCancelButtonClick(); } /* enter key */ - protected override onAccept() { - this.handleOkButtonClick(); + protected override async onAccept() { + await this.handleOkButtonClick(); } - private handleOkButtonClick(): void { + private async handleOkButtonClick(): Promise { this._okButton.enabled = false; this._cancelButton.enabled = false; this.spinner = true; - this.changePasswordFunction(this._profile, this._params, this._uri, this._passwordValueText.value, this._confirmValueText.value).then( - () => { - this.hide('ok'); /* password changed successfully */ - }, - () => { - this._okButton.enabled = true; /* ignore, user must try again */ - this._cancelButton.enabled = true; - this.spinner = false; - } - ); + try { + let result = await this.changePasswordFunction(this._profile, this._uri, this._passwordValueText.value, this._confirmValueText.value); + this.hide('ok'); /* password changed successfully */ + this._promiseResolver(result); + } + catch { + // Error encountered, keep the dialog open and reset dialog back to previous state. + this._okButton.enabled = true; /* ignore, user must try again */ + this._cancelButton.enabled = true; + this.spinner = false; + } + } - private async changePasswordFunction(connection: IConnectionProfile, params: INewConnectionParams, uri: string, oldPassword: string, newPassword: string): Promise { + private handleCancelButtonClick(): void { + this.hide('cancel'); + this._promiseResolver(undefined); + } + + private async changePasswordFunction(connection: IConnectionProfile, uri: string, oldPassword: string, newPassword: string): Promise { // Verify passwords match before changing the password. if (oldPassword !== newPassword) { this.errorMessageService.showDialog(Severity.Error, errorHeader, errorPasswordMismatchErrorMessage + '\n\n' + errorPasswordMismatchRecoveryInstructions); return Promise.reject(new Error(errorPasswordMismatchErrorMessage)); } + let passwordChangeResult = await this.connectionManagementService.changePassword(connection, uri, newPassword); if (!passwordChangeResult.result) { this.errorMessageService.showDialog(Severity.Error, errorHeader, passwordChangeResult.errorMessage); return Promise.reject(new Error(passwordChangeResult.errorMessage)); } - connection.options['password'] = newPassword; - await this.connectionDialogService.callDefaultOnConnect(connection, params); + + return newPassword; } } diff --git a/src/sql/workbench/services/connection/test/browser/connectionManagementService.test.ts b/src/sql/workbench/services/connection/test/browser/connectionManagementService.test.ts index 8389c21567..b7ddbf8380 100644 --- a/src/sql/workbench/services/connection/test/browser/connectionManagementService.test.ts +++ b/src/sql/workbench/services/connection/test/browser/connectionManagementService.test.ts @@ -20,6 +20,7 @@ import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; import { TestCapabilitiesService } from 'sql/platform/capabilities/test/common/testCapabilitiesService'; import { TestConnectionProvider } from 'sql/platform/connection/test/common/testConnectionProvider'; import { TestResourceProvider } from 'sql/workbench/services/resourceProvider/test/common/testResourceProviderService'; +import { TestErrorDiagnosticsService } from 'sql/workbench/services/connection/test/common/testErrorDiagnosticsService'; import * as azdata from 'azdata'; @@ -52,6 +53,7 @@ suite('SQL ConnectionManagementService tests', () => { let workspaceConfigurationServiceMock: TypeMoq.Mock; let resourceProviderStubMock: TypeMoq.Mock; let accountManagementService: TypeMoq.Mock; + let errorDiagnosticsService: TestErrorDiagnosticsService; let none: void; @@ -99,6 +101,7 @@ suite('SQL ConnectionManagementService tests', () => { let resourceProviderStub = new TestResourceProvider(); resourceProviderStubMock = TypeMoq.Mock.ofInstance(resourceProviderStub); accountManagementService = TypeMoq.Mock.ofType(TestAccountManagementService); + errorDiagnosticsService = new TestErrorDiagnosticsService(); let root = new ConnectionProfileGroup(ConnectionProfileGroup.RootGroupName, undefined, ConnectionProfileGroup.RootGroupName, undefined, undefined); root.connections = [ConnectionProfile.fromIConnectionProfile(capabilitiesService, connectionProfile)]; @@ -181,6 +184,7 @@ suite('SQL ConnectionManagementService tests', () => { undefined, // IQuickInputService new TestNotificationService(), resourceProviderStubMock.object, + errorDiagnosticsService, undefined, // IAngularEventingService accountManagementService.object, testLogService, // ILogService @@ -1574,7 +1578,7 @@ suite('SQL ConnectionManagementService tests', () => { const testInstantiationService = new TestInstantiationService(); testInstantiationService.stub(IStorageService, new TestStorageService()); testInstantiationService.stubCreateInstance(ConnectionStore, connectionStoreMock.object); - const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); + const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, errorDiagnosticsService, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); assert.strictEqual(profile.password, '', 'Profile should not have password initially'); assert.strictEqual(profile.options['password'], '', 'Profile options should not have password initially'); // Check for invalid profile id @@ -1604,7 +1608,7 @@ suite('SQL ConnectionManagementService tests', () => { testInstantiationService.stub(IStorageService, new TestStorageService()); testInstantiationService.stubCreateInstance(ConnectionStore, connectionStoreMock.object); - const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); + const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, errorDiagnosticsService, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); assert.strictEqual(profile.password, '', 'Profile should not have password initially'); assert.strictEqual(profile.options['password'], '', 'Profile options should not have password initially'); let credentials = await connectionManagementService.getConnectionCredentials(profile.id); @@ -1924,7 +1928,7 @@ suite('SQL ConnectionManagementService tests', () => { createInstanceStub.withArgs(ConnectionStore).returns(connectionStoreMock.object); createInstanceStub.withArgs(ConnectionStatusManager).returns(connectionStatusManagerMock.object); - const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); + const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, errorDiagnosticsService, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); // dupe connections have been seeded the numbers below already reflected the de-duped results @@ -1957,7 +1961,7 @@ test('isRecent should evaluate whether a profile was recently connected or not', }); let profile1 = createConnectionProfile('1'); let profile2 = createConnectionProfile('2'); - const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); + const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, new TestErrorDiagnosticsService(), undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); assert(connectionManagementService.isRecent(profile1)); assert(!connectionManagementService.isRecent(profile2)); }); @@ -1975,7 +1979,7 @@ test('clearRecentConnection and ConnectionsList should call connectionStore func testInstantiationService.stub(IStorageService, new TestStorageService()); sinon.stub(testInstantiationService, 'createInstance').withArgs(ConnectionStore).returns(connectionStoreMock.object); let profile1 = createConnectionProfile('1'); - const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); + const connectionManagementService = new ConnectionManagementService(undefined, testInstantiationService, undefined, undefined, undefined, new TestCapabilitiesService(), undefined, undefined, undefined, new TestErrorDiagnosticsService(), undefined, undefined, undefined, undefined, getBasicExtensionService(), undefined, undefined, undefined); connectionManagementService.clearRecentConnection(profile1); assert(called); called = false; diff --git a/src/sql/workbench/services/connection/test/common/testErrorDiagnosticsService.ts b/src/sql/workbench/services/connection/test/common/testErrorDiagnosticsService.ts new file mode 100644 index 0000000000..5a40cd0412 --- /dev/null +++ b/src/sql/workbench/services/connection/test/common/testErrorDiagnosticsService.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IErrorDiagnosticsService } from 'sql/workbench/services/diagnostics/common/errorDiagnosticsService'; + +export class TestErrorDiagnosticsService implements IErrorDiagnosticsService { + _serviceBrand: undefined; + + registerDiagnosticsProvider(providerId: string, errorDiagnostics: azdata.diagnostics.ErrorDiagnosticsProvider): void { + } + + unregisterDiagnosticsProvider(ProviderId: string): void { + } + + tryHandleConnectionError(errorCode: number, errorMessage: string, providerId: string, connection: azdata.IConnectionProfile): Promise { + return Promise.resolve({ handled: false }); + } +} diff --git a/src/sql/workbench/services/diagnostics/browser/errorDiagnosticsService.ts b/src/sql/workbench/services/diagnostics/browser/errorDiagnosticsService.ts new file mode 100644 index 0000000000..b64f16f6a9 --- /dev/null +++ b/src/sql/workbench/services/diagnostics/browser/errorDiagnosticsService.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IErrorDiagnosticsService } from 'sql/workbench/services/diagnostics/common/errorDiagnosticsService'; +import * as azdata from 'azdata'; +import { ILogService } from 'vs/platform/log/common/log'; +import * as Utils from 'sql/platform/connection/common/utils'; +import * as interfaces from 'sql/platform/connection/common/interfaces'; + +export class ErrorDiagnosticsService implements IErrorDiagnosticsService { + + _serviceBrand: undefined; + private _providers: { [handle: string]: azdata.diagnostics.ErrorDiagnosticsProvider; } = Object.create(null); + + constructor( + @ILogService private readonly _logService: ILogService + ) { } + + public async tryHandleConnectionError(errorCode: number, errorMessage: string, providerId: string, connection: interfaces.IConnectionProfile): Promise { + let result = { handled: false }; + let provider = this._providers[providerId] + if (provider) { + result = await provider.handleConnectionError(errorCode, errorMessage, Utils.convertToRpcConnectionProfile(connection)); + } + return result; + } + + /** + * Register a diagnostic provider object for a provider + * Note: only ONE diagnostic provider object can be assigned to a specific provider at a time. + * @param providerId the id of the provider to register. + * @param errorDiagnostics the actual diagnostics provider object to register under the id. + */ + public registerDiagnosticsProvider(providerId: string, errorDiagnostics: azdata.diagnostics.ErrorDiagnosticsProvider): void { + if (this._providers[providerId]) { + this._logService.error('Provider ' + providerId + ' was already registered, cannot register again.') + } + else { + this._providers[providerId] = errorDiagnostics; + } + } + + /** + * Unregister a diagnostics provider object for a provider + * @param providerId the id of the provider to unregister. + */ + public unregisterDiagnosticsProvider(providerId: string): void { + delete this._providers[providerId]; + } +} diff --git a/src/sql/workbench/services/diagnostics/common/errorDiagnosticsService.ts b/src/sql/workbench/services/diagnostics/common/errorDiagnosticsService.ts new file mode 100644 index 0000000000..000fe0ecaf --- /dev/null +++ b/src/sql/workbench/services/diagnostics/common/errorDiagnosticsService.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const SERVICE_ID = 'errorDiagnosticsService'; +export const IErrorDiagnosticsService = createDecorator(SERVICE_ID); + +export interface IErrorDiagnosticsService { + _serviceBrand: undefined; + + /** + * Register a diagnostics provider object for a provider + * Note: only ONE diagnostic provider object can be assigned to a specific provider at a time. + * @param providerId the id of the provider to be registered. + * @param errorDiagnostics the actual diagnostics provider object to be registered under the id. + */ + registerDiagnosticsProvider(providerId: string, errorDiagnostics: azdata.diagnostics.ErrorDiagnosticsProvider): void; + + /** + * Unregister a diagnostics provider object for a provider + * @param providerId the id of the provider to be unregistered. + */ + unregisterDiagnosticsProvider(ProviderId: string): void; + + /** + * Checks connection error with given parameters + * @param errorCode Error code indicating the error problem. + * @param errorMessage Error message that describes the problem in detail. + * @param providerId Identifies what provider the error comes from. + * @param connection Connection profile that is utilized for connection + * @returns a Promise containing a ConnectionDiagnosticsResult object (with handling status and altered options) + */ + tryHandleConnectionError(errorCode: number, errorMessage: string, providerId: string, connection: azdata.IConnectionProfile): Promise; +} diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2845665307..39cee94e08 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -78,10 +78,10 @@ import { MainThreadBackgroundTaskManagementShape, MainThreadConnectionManagementShape, MainThreadCredentialManagementShape, MainThreadDashboardShape, MainThreadDashboardWebviewShape, MainThreadDataProtocolShape, MainThreadExtensionManagementShape, MainThreadModalDialogShape, MainThreadModelViewDialogShape, MainThreadModelViewShape, MainThreadNotebookDocumentsAndEditorsShape, - MainThreadObjectExplorerShape, MainThreadQueryEditorShape, MainThreadResourceProviderShape, MainThreadTasksShape, + MainThreadObjectExplorerShape, MainThreadQueryEditorShape, MainThreadResourceProviderShape, MainThreadErrorDiagnosticsShape, MainThreadTasksShape, MainThreadNotebookShape as SqlMainThreadNotebookShape, MainThreadWorkspaceShape as SqlMainThreadWorkspaceShape, ExtHostAccountManagementShape, ExtHostAzureAccountShape, ExtHostConnectionManagementShape, ExtHostCredentialManagementShape, - ExtHostDataProtocolShape, ExtHostObjectExplorerShape, ExtHostResourceProviderShape, ExtHostModalDialogsShape, ExtHostTasksShape, + ExtHostDataProtocolShape, ExtHostObjectExplorerShape, ExtHostResourceProviderShape, ExtHostErrorDiagnosticsShape, ExtHostModalDialogsShape, ExtHostTasksShape, ExtHostBackgroundTaskManagementShape, ExtHostDashboardWebviewsShape, ExtHostModelViewShape, ExtHostModelViewTreeViewsShape, ExtHostDashboardShape, ExtHostModelViewDialogShape, ExtHostQueryEditorShape, ExtHostExtensionManagementShape, ExtHostAzureBlobShape, ExtHostNotebookShape as SqlExtHostNotebookShape, ExtHostWorkspaceShape as SqlExtHostWorkspaceShape, @@ -2393,6 +2393,7 @@ export const SqlMainContext = { MainThreadObjectExplorer: createProxyIdentifier('MainThreadObjectExplorer'), MainThreadBackgroundTaskManagement: createProxyIdentifier('MainThreadBackgroundTaskManagement'), MainThreadResourceProvider: createProxyIdentifier('MainThreadResourceProvider'), + MainThreadErrorDiagnostics: createProxyIdentifier('MainThreadErrorDiagnostics'), MainThreadModalDialog: createProxyIdentifier('MainThreadModalDialog'), MainThreadTasks: createProxyIdentifier('MainThreadTasks'), MainThreadDashboardWebview: createProxyIdentifier('MainThreadDashboardWebview'), @@ -2415,6 +2416,7 @@ export const SqlExtHostContext = { ExtHostDataProtocol: createProxyIdentifier('ExtHostDataProtocol'), ExtHostObjectExplorer: createProxyIdentifier('ExtHostObjectExplorer'), ExtHostResourceProvider: createProxyIdentifier('ExtHostResourceProvider'), + ExtHostErrorDiagnostics: createProxyIdentifier('ExtHostErrorDiagnostics'), ExtHostModalDialogs: createProxyIdentifier('ExtHostModalDialogs'), ExtHostTasks: createProxyIdentifier('ExtHostTasks'), ExtHostBackgroundTaskManagement: createProxyIdentifier('ExtHostBackgroundTaskManagement'), diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index af89de0945..4666367e2b 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -220,6 +220,8 @@ import { ITableDesignerService } from 'sql/workbench/services/tableDesigner/comm import { TableDesignerService } from 'sql/workbench/services/tableDesigner/browser/tableDesignerService'; import { IExecutionPlanService } from 'sql/workbench/services/executionPlan/common/interfaces'; import { ExecutionPlanService } from 'sql/workbench/services/executionPlan/common/executionPlanService'; +import { IErrorDiagnosticsService } from 'sql/workbench/services/diagnostics/common/errorDiagnosticsService'; +import { ErrorDiagnosticsService } from 'sql/workbench/services/diagnostics/browser/errorDiagnosticsService'; registerSingleton(IDashboardService, DashboardService); registerSingleton(IDashboardViewService, DashboardViewService); @@ -263,6 +265,7 @@ registerSingleton(IAssessmentService, AssessmentService); registerSingleton(IDataGridProviderService, DataGridProviderService); registerSingleton(ITableDesignerService, TableDesignerService); registerSingleton(IExecutionPlanService, ExecutionPlanService); +registerSingleton(IErrorDiagnosticsService, ErrorDiagnosticsService); //#endregion