diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json index a6794f5041..d950b228b4 100644 --- a/extensions/azurecore/package.json +++ b/extensions/azurecore/package.json @@ -151,6 +151,10 @@ "light": "resources/light/console.svg" } }, + { + "command": "azure.resource.openInAzurePortal", + "title": "%azure.openInAzurePortal.title%" + }, { "command": "azure.resource.connectsqlserver", "title": "%azure.resource.connectsqlserver.title%", diff --git a/extensions/azurecore/package.nls.json b/extensions/azurecore/package.nls.json index e2a7c1fc4b..737d01cef1 100644 --- a/extensions/azurecore/package.nls.json +++ b/extensions/azurecore/package.nls.json @@ -18,6 +18,8 @@ "azure.accounts.getSubscriptions.title": "Get Azure Account Subscriptions", "azure.accounts.getResourceGroups.title": "Get Azure Account Subscription Resource Groups", + "azure.openInAzurePortal.title": "Open in Azure Portal", + "config.azureAccountConfigurationSection": "Azure Account Configuration", "config.enablePublicCloudDescription": "Should Azure public cloud integration be enabled", "config.enableUsGovCloudDescription": "Should US Government Azure cloud (Fairfax) integration be enabled", diff --git a/extensions/azurecore/src/account-provider/interfaces.ts b/extensions/azurecore/src/account-provider/interfaces.ts index 6cbcd3d13c..91201e66cc 100644 --- a/extensions/azurecore/src/account-provider/interfaces.ts +++ b/extensions/azurecore/src/account-provider/interfaces.ts @@ -118,6 +118,8 @@ interface Settings { redirectUri?: string; scopes?: string[] + + portalEndpoint?: string } /** diff --git a/extensions/azurecore/src/account-provider/providerSettings.ts b/extensions/azurecore/src/account-provider/providerSettings.ts index 8c2bc201e8..b892266a47 100644 --- a/extensions/azurecore/src/account-provider/providerSettings.ts +++ b/extensions/azurecore/src/account-provider/providerSettings.ts @@ -52,7 +52,8 @@ const publicAzureSettings: ProviderSettings = { scopes: [ 'openid', 'email', 'profile', 'offline_access', 'https://management.azure.com/user_impersonation', - ] + ], + portalEndpoint: 'https://portal.azure.com' } } }; @@ -101,7 +102,8 @@ const usGovAzureSettings: ProviderSettings = { scopes: [ 'openid', 'email', 'profile', 'offline_access', 'https://management.usgovcloudapi.net/user_impersonation' - ] + ], + portalEndpoint: 'https://portal.azure.us' } } }; diff --git a/extensions/azurecore/src/azureResource/azure-resource.d.ts b/extensions/azurecore/src/azureResource/azure-resource.d.ts index 4db8faab22..7eadbeed6a 100644 --- a/extensions/azurecore/src/azureResource/azure-resource.d.ts +++ b/extensions/azurecore/src/azureResource/azure-resource.d.ts @@ -24,6 +24,7 @@ export namespace azureResource { export interface AzureResource { name: string; id: string; + tenant?: string; } export interface AzureResourceSubscription extends AzureResource { diff --git a/extensions/azurecore/src/azureResource/commands.ts b/extensions/azurecore/src/azureResource/commands.ts index 239877da79..39757c0e83 100644 --- a/extensions/azurecore/src/azureResource/commands.ts +++ b/extensions/azurecore/src/azureResource/commands.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ -import { window, QuickPickItem } from 'vscode'; +import { window, QuickPickItem, env, Uri } from 'vscode'; import * as azdata from 'azdata'; import { TokenCredentials } from '@azure/ms-rest-js'; import * as nls from 'vscode-nls'; @@ -237,4 +237,23 @@ export function registerAzureResourceCommands(appContext: AppContext, tree: Azur appContext.apiWrapper.executeCommand('workbench.view.connections'); } }); + + appContext.apiWrapper.registerCommand('azure.resource.openInAzurePortal', async (node?: TreeNode) => { + if (!node) { + return; + } + + const treeItem: azdata.TreeItem = await node.getTreeItem(); + if (!treeItem.payload) { + return; + } + let connectionProfile = Object.assign({}, treeItem.payload, { saveProfile: true }); + + if (!connectionProfile.azureResourceId) { + return; + } + + const urlToOpen = `${connectionProfile.azurePortalEndpoint}//${connectionProfile.azureTenantId}/#resource/${connectionProfile.azureResourceId}`; + env.openExternal(Uri.parse(urlToOpen)); + }); } diff --git a/extensions/azurecore/src/azureResource/providers/database/databaseService.ts b/extensions/azurecore/src/azureResource/providers/database/databaseService.ts index b26ee45cdc..a8f3231e0c 100644 --- a/extensions/azurecore/src/azureResource/providers/database/databaseService.ts +++ b/extensions/azurecore/src/azureResource/providers/database/databaseService.ts @@ -50,7 +50,8 @@ export class AzureResourceDatabaseService implements IAzureResourceService>; @@ -21,7 +23,7 @@ let mockApiWrapper: TypeMoq.IMock; let mockExtensionContext: TypeMoq.IMock; // Mock test data -const mockAccount: azdata.Account = { +const mockAccount: AzureAccount = { key: { accountId: 'mock_account', providerId: 'mock_provider' @@ -32,7 +34,11 @@ const mockAccount: azdata.Account = { contextualDisplayName: 'test', userId: 'test@email.com' }, - properties: undefined, + properties: { + providerSettings: settings[0].metadata, + isMsAccount: true, + tenants: [] + }, isStale: false }; diff --git a/extensions/azurecore/src/test/azureResource/providers/databaseServer/databaseServerTreeDataProvider.test.ts b/extensions/azurecore/src/test/azureResource/providers/databaseServer/databaseServerTreeDataProvider.test.ts index 075c4d20e9..64c69dfaa2 100644 --- a/extensions/azurecore/src/test/azureResource/providers/databaseServer/databaseServerTreeDataProvider.test.ts +++ b/extensions/azurecore/src/test/azureResource/providers/databaseServer/databaseServerTreeDataProvider.test.ts @@ -19,9 +19,11 @@ import { IAzureResourceService } from '../../../../azureResource/interfaces'; let mockDatabaseServerService: TypeMoq.IMock>; let mockApiWrapper: TypeMoq.IMock; let mockExtensionContext: TypeMoq.IMock; +import settings from '../../../../account-provider/providerSettings'; +import { AzureAccount } from '../../../../account-provider/interfaces'; // Mock test data -const mockAccount: azdata.Account = { +const mockAccount: AzureAccount = { key: { accountId: 'mock_account', providerId: 'mock_provider' @@ -32,7 +34,11 @@ const mockAccount: azdata.Account = { contextualDisplayName: 'test', userId: 'test@email.com' }, - properties: undefined, + properties: { + providerSettings: settings[0].metadata, + isMsAccount: true, + tenants: [] + }, isStale: false }; diff --git a/extensions/azurecore/src/test/azureResource/resourceService.test.ts b/extensions/azurecore/src/test/azureResource/resourceService.test.ts index b256b8f6d7..061c4cb2d1 100644 --- a/extensions/azurecore/src/test/azureResource/resourceService.test.ts +++ b/extensions/azurecore/src/test/azureResource/resourceService.test.ts @@ -5,15 +5,16 @@ import * as should from 'should'; import * as TypeMoq from 'typemoq'; -import * as azdata from 'azdata'; import 'mocha'; import { fail } from 'assert'; import { azureResource } from '../../azureResource/azure-resource'; import { AzureResourceService } from '../../azureResource/resourceService'; +import { AzureAccount } from '../../account-provider/interfaces'; +import settings from '../../account-provider/providerSettings'; // Mock test data -const mockAccount: azdata.Account = { +const mockAccount: AzureAccount = { key: { accountId: 'mock_account', providerId: 'mock_provider' @@ -24,7 +25,11 @@ const mockAccount: azdata.Account = { contextualDisplayName: 'test', userId: 'test@email.com' }, - properties: undefined, + properties: { + providerSettings: settings[0].metadata, + isMsAccount: true, + tenants: [] + }, isStale: false }; diff --git a/extensions/azurecore/src/test/azureResource/resourceTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/resourceTreeNode.test.ts index f1c6761d7e..6fa491ff3d 100644 --- a/extensions/azurecore/src/test/azureResource/resourceTreeNode.test.ts +++ b/extensions/azurecore/src/test/azureResource/resourceTreeNode.test.ts @@ -5,7 +5,6 @@ import * as should from 'should'; import * as TypeMoq from 'typemoq'; -import * as azdata from 'azdata'; import * as vscode from 'vscode'; import 'mocha'; @@ -15,9 +14,11 @@ import { AzureResourceResourceTreeNode } from '../../azureResource/resourceTreeN import { AppContext } from '../../appContext'; import { ApiWrapper } from '../../apiWrapper'; import { AzureResourceServiceNames } from '../../azureResource/constants'; +import settings from '../../account-provider/providerSettings'; +import { AzureAccount } from '../../account-provider/interfaces'; // Mock test data -const mockAccount: azdata.Account = { +const mockAccount: AzureAccount = { key: { accountId: 'mock_account', providerId: 'mock_provider' @@ -28,7 +29,11 @@ const mockAccount: azdata.Account = { contextualDisplayName: 'test', userId: 'test@email.com' }, - properties: undefined, + properties: { + providerSettings: settings[0].metadata, + isMsAccount: true, + tenants: [] + }, isStale: false }; @@ -87,7 +92,7 @@ let mockResourceProvider: TypeMoq.IMock; let resourceService: AzureResourceService; let appContext: AppContext; -describe('AzureResourceResourceTreeNode.info', function(): void { +describe('AzureResourceResourceTreeNode.info', function (): void { beforeEach(() => { mockResourceTreeDataProvider = TypeMoq.Mock.ofType(); mockResourceTreeDataProvider.setup((o) => o.getTreeItem(mockResourceRootNode)).returns(() => mockResourceRootNode.treeItem); @@ -105,7 +110,7 @@ describe('AzureResourceResourceTreeNode.info', function(): void { appContext.registerService(AzureResourceServiceNames.resourceService, resourceService); }); - it('Should be correct when created.', async function(): Promise { + it('Should be correct when created.', async function (): Promise { const resourceTreeNode = new AzureResourceResourceTreeNode({ resourceProviderId: mockResourceProviderId, resourceNode: mockResourceRootNode @@ -127,7 +132,7 @@ describe('AzureResourceResourceTreeNode.info', function(): void { }); }); -describe('AzureResourceResourceTreeNode.getChildren', function(): void { +describe('AzureResourceResourceTreeNode.getChildren', function (): void { beforeEach(() => { mockResourceTreeDataProvider = TypeMoq.Mock.ofType(); mockResourceTreeDataProvider.setup((o) => o.getChildren(mockResourceRootNode)).returns(() => Promise.resolve(mockResourceNodes)); @@ -145,12 +150,12 @@ describe('AzureResourceResourceTreeNode.getChildren', function(): void { appContext.registerService(AzureResourceServiceNames.resourceService, resourceService); }); - it('Should return resource nodes when it is container node.', async function(): Promise { + it('Should return resource nodes when it is container node.', async function (): Promise { const resourceTreeNode = new AzureResourceResourceTreeNode({ resourceProviderId: mockResourceProviderId, resourceNode: mockResourceRootNode }, - undefined, appContext); + undefined, appContext); const children = await resourceTreeNode.getChildren(); @@ -176,7 +181,7 @@ describe('AzureResourceResourceTreeNode.getChildren', function(): void { } }); - it('Should return empty when it is leaf node.', async function(): Promise { + it('Should return empty when it is leaf node.', async function (): Promise { const resourceTreeNode = new AzureResourceResourceTreeNode({ resourceProviderId: mockResourceProviderId, resourceNode: mockResourceNode1 diff --git a/src/sql/azdata.d.ts b/src/sql/azdata.d.ts index b0ffcaa608..8f7d5deaed 100644 --- a/src/sql/azdata.d.ts +++ b/src/sql/azdata.d.ts @@ -333,6 +333,7 @@ declare module 'azdata' { saveProfile: boolean; id: string; azureTenantId?: string; + } /** diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 560f9da842..7d1c6edc64 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -111,6 +111,8 @@ declare module 'azdata' { */ export interface IConnectionProfile extends ConnectionInfo { azureAccount?: string; + azureResourceId?: string; + azurePortalEndpoint?: string; } /* diff --git a/src/sql/workbench/contrib/azure/browser/azure.contribution.ts b/src/sql/workbench/contrib/azure/browser/azure.contribution.ts new file mode 100644 index 0000000000..856ae4adca --- /dev/null +++ b/src/sql/workbench/contrib/azure/browser/azure.contribution.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; +import { localize } from 'vs/nls'; +import { MssqlNodeContext } from 'sql/workbench/services/objectExplorer/browser/mssqlNodeContext'; + +MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { + group: 'z-azurecore', + order: 1, + command: { + id: 'azure.resource.openInAzurePortal', + title: localize('azure.openInAzurePortal.title', "Open in Azure Portal") + }, + when: MssqlNodeContext.CanOpenInAzurePortal +}); + diff --git a/src/sql/workbench/services/objectExplorer/browser/mssqlNodeContext.ts b/src/sql/workbench/services/objectExplorer/browser/mssqlNodeContext.ts index 31852cfdff..0efbd365c9 100644 --- a/src/sql/workbench/services/objectExplorer/browser/mssqlNodeContext.ts +++ b/src/sql/workbench/services/objectExplorer/browser/mssqlNodeContext.ts @@ -34,6 +34,7 @@ export class MssqlNodeContext extends Disposable { static NodeType = new RawContextKey('nodeType', undefined); static NodeLabel = new RawContextKey('nodeLabel', undefined); static EngineEdition = new RawContextKey('engineEdition', DatabaseEngineEdition.Unknown); + static CanOpenInAzurePortal = new RawContextKey('canOpenInAzurePortal', false); // Scripting context keys static CanScriptAsSelect = new RawContextKey('canScriptAsSelect', false); @@ -48,6 +49,7 @@ export class MssqlNodeContext extends Disposable { private nodeLabelKey: IContextKey; private isDatabaseOrServerKey: IContextKey; private engineEditionKey: IContextKey; + private canOpenInAzurePortal: IContextKey; private canScriptAsSelectKey: IContextKey; private canEditDataKey: IContextKey; @@ -71,6 +73,7 @@ export class MssqlNodeContext extends Disposable { this.setNodeProvider(); this.setIsCloud(); this.setEngineEdition(); + this.setCanOpenInPortal(); if (node.type) { this.setIsDatabaseOrServer(); this.nodeTypeKey.set(node.type); @@ -98,6 +101,7 @@ export class MssqlNodeContext extends Disposable { this.canScriptAsExecuteKey = MssqlNodeContext.CanScriptAsExecute.bindTo(this.contextKeyService); this.canScriptAsAlterKey = MssqlNodeContext.CanScriptAsAlter.bindTo(this.contextKeyService); this.nodeProviderKey = MssqlNodeContext.NodeProvider.bindTo(this.contextKeyService); + this.canOpenInAzurePortal = MssqlNodeContext.CanOpenInAzurePortal.bindTo(this.contextKeyService); } /** @@ -121,6 +125,16 @@ export class MssqlNodeContext extends Disposable { } } + private setCanOpenInPortal(): void { + const connectionProfile = this.nodeContextValue.node.payload; + if (connectionProfile && + connectionProfile.azureResourceId && + connectionProfile.azureTenantId && + connectionProfile.azurePortalEndpoint) { + this.canOpenInAzurePortal.set(true); + } + } + /** * Helper function to set engine edition */ diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index b50759875c..824692351d 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -469,4 +469,7 @@ import 'sql/workbench/contrib/resourceDeployment/browser/resourceDeployment.cont // Extension import 'sql/workbench/contrib/extensions/browser/extensions.contribution'; +// Azure +import 'sql/workbench/contrib/azure/browser/azure.contribution'; + //#endregion