From f6e70d28c9d82bba9f622d8082d5d4a270ede08c Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Tue, 13 Jun 2023 21:23:00 -0700 Subject: [PATCH] Introduce Tenant hierarchy in Azure resource tree for multi-tenant accounts (#23311) --- extensions/azurecore/package.json | 63 ++- extensions/azurecore/package.nls.json | 1 + .../resources/azureSynapseAnalytics.svg | 26 ++ extensions/azurecore/resources/cosmosDb.svg | 22 + .../resources/dataExplorerClusterDb.svg | 32 ++ .../resources/logAnalyticsWorkspaces.svg | 21 + .../azurecore/resources/mysqlDatabase.svg | 20 + extensions/azurecore/resources/sqlPools.svg | 28 ++ .../azurecore/resources/subscriptions.svg | 15 + extensions/azurecore/resources/tenant.svg | 23 ++ extensions/azurecore/resources/users.svg | 16 + .../src/account-provider/auths/azureAuth.ts | 6 +- .../azurecore/src/azureResource/commands.ts | 79 +++- .../azurecore/src/azureResource/constants.ts | 4 + .../azurecore/src/azureResource/interfaces.ts | 9 +- .../azuremonitorTreeDataProvider.ts | 14 +- .../mongo/cosmosDbMongoTreeDataProvider.ts | 14 +- .../database/databaseTreeDataProvider.ts | 14 +- .../databaseServerTreeDataProvider.ts | 14 +- .../providers/kusto/kustoTreeDataProvider.ts | 14 +- .../mysqlFlexibleServerTreeDataProvider.ts | 12 +- .../postgresArcServerTreeDataProvider.ts | 14 +- .../postgresFlexibleServerTreeDataProvider.ts | 12 +- .../postgresServerTreeDataProvider.ts | 10 +- .../sqlInstanceTreeDataProvider.ts | 14 +- .../sqlInstanceArcTreeDataProvider.ts | 14 +- .../synapseSqlPoolTreeDataProvider.ts | 12 +- .../synapseWorkspaceTreeDataProvider.ts | 12 +- .../services/subscriptionFilterService.ts | 38 +- .../services/tenantFilterService.ts | 54 +++ .../src/azureResource/tree/accountTreeNode.ts | 140 +++---- .../src/azureResource/tree/baseTreeNodes.ts | 2 - .../tree/connectionDialogTreeProvider.ts | 5 +- .../azureResource/tree/flatAccountTreeNode.ts | 247 ++++------- .../azureResource/tree/flatTenantTreeNode.ts | 180 ++++++++ .../tree/subscriptionTreeNode.ts | 11 +- .../src/azureResource/tree/tenantTreeNode.ts | 156 +++++++ .../src/azureResource/tree/treeProvider.ts | 19 +- .../azurecore/src/azureResource/treeNode.ts | 4 +- .../azurecore/src/azureResource/utils.ts | 8 +- extensions/azurecore/src/extension.ts | 6 +- .../database/databaseTreeDataProvider.test.ts | 6 +- .../databaseServerTreeDataProvider.test.ts | 8 +- .../tree/accountTreeNode.test.ts | 385 +++++++++--------- .../tree/subscriptionTreeNode.test.ts | 15 +- .../browser/media/accountListRenderer.css | 3 +- 46 files changed, 1167 insertions(+), 655 deletions(-) create mode 100644 extensions/azurecore/resources/azureSynapseAnalytics.svg create mode 100644 extensions/azurecore/resources/cosmosDb.svg create mode 100644 extensions/azurecore/resources/dataExplorerClusterDb.svg create mode 100644 extensions/azurecore/resources/logAnalyticsWorkspaces.svg create mode 100644 extensions/azurecore/resources/mysqlDatabase.svg create mode 100644 extensions/azurecore/resources/sqlPools.svg create mode 100644 extensions/azurecore/resources/subscriptions.svg create mode 100644 extensions/azurecore/resources/tenant.svg create mode 100644 extensions/azurecore/resources/users.svg create mode 100644 extensions/azurecore/src/azureResource/services/tenantFilterService.ts create mode 100644 extensions/azurecore/src/azureResource/tree/flatTenantTreeNode.ts create mode 100644 extensions/azurecore/src/azureResource/tree/tenantTreeNode.ts diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json index 7a7307b7ea..c34d737b9f 100644 --- a/extensions/azurecore/package.json +++ b/extensions/azurecore/package.json @@ -136,15 +136,15 @@ { "id": "microsoft", "icon": { - "light": "./resources/light/microsoft_account_light.svg", - "dark": "./resources/dark/microsoft_account_dark.svg" + "light": "./resources/users.svg", + "dark": "./resources/users.svg" } }, { "id": "work_school", "icon": { - "light": "./resources/light/work_school_account_light.svg", - "dark": "./resources/dark/work_school_account_dark.svg" + "light": "./resources/users.svg", + "dark": "./resources/users.svg" } } ], @@ -174,6 +174,11 @@ "title": "%azure.resource.refresh.title%", "icon": "$(refresh)" }, + { + "command": "azure.resource.selecttenants", + "title": "%azure.resource.selecttenants.title%", + "icon": "$(filter)" + }, { "command": "azure.resource.selectsubscriptions", "title": "%azure.resource.selectsubscriptions.title%", @@ -237,6 +242,10 @@ "command": "azure.resource.selectsubscriptions", "when": "false" }, + { + "command": "azure.resource.selecttenants", + "when": "false" + }, { "command": "azure.resource.azureview.refresh", "when": "false" @@ -267,24 +276,44 @@ } ], "view/item/context": [ + { + "command": "azure.resource.selecttenants", + "when": "viewItem == azure.resource.itemType.multipleTenantAccount", + "group": "inline" + }, + { + "command": "azure.resource.selecttenants", + "when": "viewItem == azure.resource.itemType.multipleTenantAccount", + "group": "azurecore" + }, { "command": "azure.resource.selectsubscriptions", - "when": "viewItem == azure.resource.itemType.account", + "when": "viewItem == azure.resource.itemType.singleTenantAccount", "group": "inline" }, { "command": "azure.resource.selectsubscriptions", - "when": "viewItem == azure.resource.itemType.account", + "when": "viewItem == azure.resource.itemType.singleTenantAccount", + "group": "azurecore" + }, + { + "command": "azure.resource.selectsubscriptions", + "when": "viewItem == azure.resource.itemType.tenant", + "group": "inline" + }, + { + "command": "azure.resource.selectsubscriptions", + "when": "viewItem == azure.resource.itemType.tenant", "group": "azurecore" }, { "command": "azure.resource.azureview.refresh", - "when": "viewItem =~ /^azure\\.resource\\.itemType\\.(?:account|subscription|databaseContainer|databaseServerContainer|synapseSqlPoolContainer|synapseWorkspaceContainer)$/", + "when": "viewItem =~ /^azure\\.resource\\.itemType\\.(?:singleTenantAccount|multipleTenantAccount|subscription|tenant|databaseContainer|databaseServerContainer|synapseSqlPoolContainer|synapseWorkspaceContainer)$/", "group": "inline" }, { "command": "azure.resource.azureview.refresh", - "when": "viewItem =~ /^azure\\.resource\\.itemType\\.(?:account|subscription|databaseContainer|databaseServerContainer|synapseSqlPoolContainer|synapseWorkspaceContainer)$/", + "when": "viewItem =~ /^azure\\.resource\\.itemType\\.(?:singleTenantAccount|multipleTenantAccount|subscription|tenant|databaseContainer|databaseServerContainer|synapseSqlPoolContainer|synapseWorkspaceContainer)$/", "group": "azurecore" }, { @@ -299,7 +328,7 @@ }, { "command": "azure.resource.startterminal", - "when": "viewItem == azure.resource.itemType.account", + "when": "viewItem =~ /^azure\\.resource\\.itemType\\.(?:singleTenantAccount|multipleTenantAccount)$/", "group": "inline" }, { @@ -314,19 +343,29 @@ }, { "command": "azure.resource.startterminal", - "when": "viewItem == azure.resource.itemType.account", + "when": "viewItem =~ /^azure\\.resource\\.itemType\\.(?:singleTenantAccount|multipleTenantAccount)$/", "group": "azurecore" } ], "connectionDialog/browseTree": [ { "command": "azure.resource.selectsubscriptions", - "when": "contextValue == azure.resource.itemType.account", + "when": "contextValue == azure.resource.itemType.tenant", + "group": "navigation" + }, + { + "command": "azure.resource.selecttenants", + "when": "contextValue == azure.resource.itemType.multipleTenantAccount", + "group": "navigation" + }, + { + "command": "azure.resource.selectsubscriptions", + "when": "contextValue == azure.resource.itemType.singleTenantAccount", "group": "navigation" }, { "command": "azure.resource.connectiondialog.refresh", - "when": "contextValue == azure.resource.itemType.account", + "when": "contextValue =~ /^azure\\.resource\\.itemType\\.(?:singleTenantAccount|multipleTenantAccount|tenant)$/", "group": "navigation" }, { diff --git a/extensions/azurecore/package.nls.json b/extensions/azurecore/package.nls.json index a3e9b1949f..8c0c71ffa1 100644 --- a/extensions/azurecore/package.nls.json +++ b/extensions/azurecore/package.nls.json @@ -9,6 +9,7 @@ "azure.resource.refresh.title": "Refresh", "azure.resource.signin.title": "Azure: Sign In", "azure.resource.selectsubscriptions.title": "Select Subscriptions", + "azure.resource.selecttenants.title": "Select Tenants", "azure.resource.startterminal.title": "Start Cloud Shell", "azure.resource.connectsqlserver.title": "Connect", "azure.resource.connectsqldb.title": "Add to Servers", diff --git a/extensions/azurecore/resources/azureSynapseAnalytics.svg b/extensions/azurecore/resources/azureSynapseAnalytics.svg new file mode 100644 index 0000000000..ec3d880261 --- /dev/null +++ b/extensions/azurecore/resources/azureSynapseAnalytics.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/cosmosDb.svg b/extensions/azurecore/resources/cosmosDb.svg new file mode 100644 index 0000000000..cd4106e79d --- /dev/null +++ b/extensions/azurecore/resources/cosmosDb.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + Icon-databases-121 + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/dataExplorerClusterDb.svg b/extensions/azurecore/resources/dataExplorerClusterDb.svg new file mode 100644 index 0000000000..c598abebeb --- /dev/null +++ b/extensions/azurecore/resources/dataExplorerClusterDb.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + Icon-analytics-145 + + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/logAnalyticsWorkspaces.svg b/extensions/azurecore/resources/logAnalyticsWorkspaces.svg new file mode 100644 index 0000000000..6e3efc8de5 --- /dev/null +++ b/extensions/azurecore/resources/logAnalyticsWorkspaces.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + Icon-manage-307 + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/mysqlDatabase.svg b/extensions/azurecore/resources/mysqlDatabase.svg new file mode 100644 index 0000000000..dfae0e012d --- /dev/null +++ b/extensions/azurecore/resources/mysqlDatabase.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + Icon-databases-122 + + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/sqlPools.svg b/extensions/azurecore/resources/sqlPools.svg new file mode 100644 index 0000000000..59fd8a2cf8 --- /dev/null +++ b/extensions/azurecore/resources/sqlPools.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + Icon-databases-125 + + + + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/subscriptions.svg b/extensions/azurecore/resources/subscriptions.svg new file mode 100644 index 0000000000..05c73987f2 --- /dev/null +++ b/extensions/azurecore/resources/subscriptions.svg @@ -0,0 +1,15 @@ + + + + + + + + + + Icon-general-2 + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/tenant.svg b/extensions/azurecore/resources/tenant.svg new file mode 100644 index 0000000000..130e24b2c8 --- /dev/null +++ b/extensions/azurecore/resources/tenant.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + Icon-identity-221 + + + + + + + + \ No newline at end of file diff --git a/extensions/azurecore/resources/users.svg b/extensions/azurecore/resources/users.svg new file mode 100644 index 0000000000..fecd345aef --- /dev/null +++ b/extensions/azurecore/resources/users.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + Icon-identity-230 + + + + diff --git a/extensions/azurecore/src/account-provider/auths/azureAuth.ts b/extensions/azurecore/src/account-provider/auths/azureAuth.ts index 2a75cd9406..e91a71111c 100644 --- a/extensions/azurecore/src/account-provider/auths/azureAuth.ts +++ b/extensions/azurecore/src/account-provider/auths/azureAuth.ts @@ -194,7 +194,7 @@ export abstract class AzureAuth implements vscode.Disposable { public async hydrateAccount(token: Token | AccessToken, tokenClaims: TokenClaims): Promise { let account: azdata.Account; if (this._authLibrary === Constants.AuthLibrary.MSAL) { - const tenants = await this.getTenantsMsal(token.token); + const tenants = await this.getTenantsMsal(token.token, tokenClaims); account = this.createAccount(tokenClaims, token.key, tenants); } else { // fallback to ADAL as default const tenants = await this.getTenantsAdal({ ...token }); @@ -471,7 +471,7 @@ export abstract class AzureAuth implements vscode.Disposable { return result; } - public async getTenantsMsal(token: string): Promise { + public async getTenantsMsal(token: string, tokenClaims: TokenClaims): Promise { const tenantUri = url.resolve(this.metadata.settings.armResource.endpoint, 'tenants?api-version=2019-11-01'); try { Logger.verbose(`Fetching tenants with uri: ${tenantUri}`); @@ -499,7 +499,7 @@ export abstract class AzureAuth implements vscode.Disposable { return { id: tenantInfo.tenantId, displayName: tenantInfo.displayName ? tenantInfo.displayName : tenantInfo.tenantId, - userId: token, + userId: tokenClaims.oid, tenantCategory: tenantInfo.tenantCategory } as Tenant; }); diff --git a/extensions/azurecore/src/azureResource/commands.ts b/extensions/azurecore/src/azureResource/commands.ts index e9167030c1..f6e8e6f266 100644 --- a/extensions/azurecore/src/azureResource/commands.ts +++ b/extensions/azurecore/src/azureResource/commands.ts @@ -12,12 +12,14 @@ import { AppContext } from '../appContext'; import { TreeNode } from './treeNode'; import { AzureResourceTreeProvider } from './tree/treeProvider'; import { AzureResourceAccountTreeNode } from './tree/accountTreeNode'; -import { IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService, IAzureTerminalService } from '../azureResource/interfaces'; +import { IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService, IAzureTerminalService, IAzureResourceTenantFilterService } from '../azureResource/interfaces'; import { AzureResourceServiceNames } from './constants'; import { AzureAccount, Tenant, azureResource } from 'azurecore'; -import { FlatAccountTreeNode } from './tree/flatAccountTreeNode'; +import { FlatTenantTreeNode } from './tree/flatTenantTreeNode'; import { ConnectionDialogTreeProvider } from './tree/connectionDialogTreeProvider'; import { AzureResourceErrorMessageUtil, filterAccounts } from './utils'; +import { AzureResourceTenantTreeNode } from './tree/tenantTreeNode'; +import { FlatAccountTreeNode } from './tree/flatAccountTreeNode'; export function registerAzureResourceCommands(appContext: AppContext, azureViewTree: AzureResourceTreeProvider, connectionDialogTree: ConnectionDialogTreeProvider, authLibrary: string): void { const trees = [azureViewTree, connectionDialogTree]; @@ -95,14 +97,21 @@ export function registerAzureResourceCommands(appContext: AppContext, azureViewT }); // Resource Tree commands - + // Supports selecting subscriptions from single tenant account tree nodes or tenant tree node. vscode.commands.registerCommand('azure.resource.selectsubscriptions', async (node?: TreeNode) => { - if (!(node instanceof AzureResourceAccountTreeNode) && !(node instanceof FlatAccountTreeNode)) { + if (!(node instanceof AzureResourceAccountTreeNode) && !(node instanceof FlatAccountTreeNode) + && !(node instanceof AzureResourceTenantTreeNode) && !(node instanceof FlatTenantTreeNode)) { return; } const account = node.account; - if (!account) { + + // Select first tenant from single tenant accounts + let tenant = node.account.properties.tenants[0]; + if (node instanceof AzureResourceTenantTreeNode || node instanceof FlatTenantTreeNode) { + tenant = node.tenant; + } + if (!account || !tenant) { return; } @@ -112,7 +121,8 @@ export function registerAzureResourceCommands(appContext: AppContext, azureViewT let subscriptions: azureResource.AzureResourceSubscription[] = []; if (subscriptions.length === 0) { try { - subscriptions = await subscriptionService.getSubscriptions(account); + let tenantIds = tenant ? [tenant.id] : account.properties.tenants.flatMap(t => t.id); + subscriptions = await subscriptionService.getSubscriptions(account, tenantIds); } catch (error) { account.isStale = true; void vscode.window.showErrorMessage(AzureResourceErrorMessageUtil.getErrorMessage(error)); @@ -120,7 +130,7 @@ export function registerAzureResourceCommands(appContext: AppContext, azureViewT } } - let selectedSubscriptions = await subscriptionFilterService.getSelectedSubscriptions(account); + let selectedSubscriptions = await subscriptionFilterService.getSelectedSubscriptions(account, tenant); if (!selectedSubscriptions) { selectedSubscriptions = []; } @@ -152,7 +162,57 @@ export function registerAzureResourceCommands(appContext: AppContext, azureViewT } selectedSubscriptions = selectedSubscriptionQuickPickItems.map((subscriptionItem) => subscriptionItem.subscription); - await subscriptionFilterService.saveSelectedSubscriptions(account, selectedSubscriptions); + await subscriptionFilterService.saveSelectedSubscriptions(account, tenant, selectedSubscriptions); + } + }); + + vscode.commands.registerCommand('azure.resource.selecttenants', async (node?: TreeNode) => { + if (!(node instanceof AzureResourceAccountTreeNode) && !(node instanceof FlatAccountTreeNode)) { + return; + } + + const account = node.account; + if (!account) { + return; + } + + const tenantFilterService = appContext.getService(AzureResourceServiceNames.tenantFilterService); + + let tenants = account.properties.tenants; + + let selectedTenants = await tenantFilterService.getSelectedTenants(account); + if (!selectedTenants) { + selectedTenants = []; + } + + const selectedTenantIds: string[] = []; + if (selectedTenants.length > 0) { + selectedTenantIds.push(...selectedTenants.map((tenant) => tenant.id)); + } else { + // ALL tenants are selected by default + selectedTenantIds.push(...tenants.map((tenant) => tenant.id)); + } + + interface AzureResourceTenantQuickPickItem extends vscode.QuickPickItem { + tenant: Tenant; + } + + const tenantQuickPickItems: AzureResourceTenantQuickPickItem[] = tenants.map(tenant => { + return { + label: tenant.displayName, + picked: selectedTenantIds.indexOf(tenant.id) !== -1, + tenant: tenant + }; + }).sort((a, b) => a.label.localeCompare(b.label)); + + const selectedtenantQuickPickItems = await vscode.window.showQuickPick(tenantQuickPickItems, { canPickMany: true }); + if (selectedtenantQuickPickItems && selectedtenantQuickPickItems.length > 0) { + for (const tree of trees) { + await tree.refresh(undefined, false); + } + + selectedTenants = selectedtenantQuickPickItems.map((item) => item.tenant); + await tenantFilterService.saveSelectedTenants(account, selectedTenants); } }); @@ -167,7 +227,8 @@ export function registerAzureResourceCommands(appContext: AppContext, azureViewT }); vscode.commands.registerCommand('azure.resource.connectiondialog.refresh', async (node?: TreeNode) => { - return connectionDialogTree.refresh(node, true); + await connectionDialogTree.refresh(node, true); // clear cache first + return connectionDialogTree.refresh(node, false); }); vscode.commands.registerCommand('azure.resource.signin', async (node?: TreeNode) => { diff --git a/extensions/azurecore/src/azureResource/constants.ts b/extensions/azurecore/src/azureResource/constants.ts index e32fb39960..bbc52bf3e7 100644 --- a/extensions/azurecore/src/azureResource/constants.ts +++ b/extensions/azurecore/src/azureResource/constants.ts @@ -5,7 +5,10 @@ export enum AzureResourceItemType { account = 'azure.resource.itemType.account', + singleTenantAccount = 'azure.resource.itemType.singleTenantAccount', + multipleTenantAccount = 'azure.resource.itemType.multipleTenantAccount', subscription = 'azure.resource.itemType.subscription', + tenant = 'azure.resource.itemType.tenant', databaseContainer = 'azure.resource.itemType.databaseContainer', database = 'azure.resource.itemType.database', databaseServerContainer = 'azure.resource.itemType.databaseServerContainer', @@ -32,6 +35,7 @@ export enum AzureResourceServiceNames { subscriptionService = 'AzureResourceSubscriptionService', subscriptionFilterService = 'AzureResourceSubscriptionFilterService', tenantService = 'AzureResourceTenantService', + tenantFilterService = 'AzureResourceTenantFilterService', terminalService = 'AzureTerminalService', } diff --git a/extensions/azurecore/src/azureResource/interfaces.ts b/extensions/azurecore/src/azureResource/interfaces.ts index 5b3c9a7786..5e4df246f0 100644 --- a/extensions/azurecore/src/azureResource/interfaces.ts +++ b/extensions/azurecore/src/azureResource/interfaces.ts @@ -135,9 +135,14 @@ export interface IAzureResourceSubscriptionService { getSubscriptions(account: AzureAccount, tenantIds?: string[] | undefined): Promise; } +export interface IAzureResourceTenantFilterService { + getSelectedTenants(account: AzureAccount): Promise; + saveSelectedTenants(account: AzureAccount, selectedTenants: Tenant[]): Promise; +} + export interface IAzureResourceSubscriptionFilterService { - getSelectedSubscriptions(account: AzureAccount): Promise; - saveSelectedSubscriptions(account: AzureAccount, selectedSubscriptions: azureResource.AzureResourceSubscription[]): Promise; + getSelectedSubscriptions(account: AzureAccount, tenant: Tenant): Promise; + saveSelectedSubscriptions(account: AzureAccount, tenant: Tenant, selectedSubscriptions: azureResource.AzureResourceSubscription[]): Promise; } export interface IAzureTerminalService { diff --git a/extensions/azurecore/src/azureResource/providers/azuremonitor/azuremonitorTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/azuremonitor/azuremonitorTreeDataProvider.ts index 357a50679b..522edf3e02 100644 --- a/extensions/azurecore/src/azureResource/providers/azuremonitor/azuremonitorTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/azuremonitor/azuremonitorTreeDataProvider.ts @@ -16,7 +16,7 @@ import { AzureAccount, azureResource } from 'azurecore'; export class AzureMonitorTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.AzureMonitorContainer'; - private static readonly containerLabel = localize('azure.resource.providers.AzureMonitorContainerLabel', "Log Analytics workspace"); + private static readonly containerLabel = localize('azure.resource.providers.AzureMonitorContainerLabel', "Log Analytics workspaces"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -27,12 +27,9 @@ export class AzureMonitorTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly CONTAINER_ID = 'azure.resource.providers.databaseServer.treeDataProvider.cosmosDbMongoContainer'; - private static readonly CONTAINER_LABEL = localize('azure.resource.providers.databaseServer.treeDataProvider.cosmosDbMongoContainerLabel', "CosmosDB for Mongo"); + private static readonly CONTAINER_LABEL = localize('azure.resource.providers.databaseServer.treeDataProvider.cosmosDbMongoContainerLabel', "Azure CosmosDB for MongoDB"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -28,12 +28,9 @@ export class CosmosDbMongoTreeDataProvider extends ResourceTreeDataProviderBase< public getTreeItemForResource(databaseServer: AzureResourceMongoDatabaseServer, account: azdata.Account): azdata.TreeItem { return { - id: `${AzureResourcePrefixes.cosmosdb}${account.key.accountId}${databaseServer.id ?? databaseServer.name}`, + id: `${AzureResourcePrefixes.cosmosdb}${account.key.accountId}${databaseServer.tenant}${databaseServer.id ?? databaseServer.name}`, label: `${databaseServer.name} (CosmosDB Mongo API)`, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/cosmosdb_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/cosmosdb.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/cosmosDb.svg'), collapsibleState: TreeItemCollapsibleState.None, contextValue: AzureResourceItemType.cosmosDBMongoAccount, payload: { @@ -65,10 +62,7 @@ export class CosmosDbMongoTreeDataProvider extends ResourceTreeDataProviderBase< return [{ id: CosmosDbMongoTreeDataProvider.CONTAINER_ID, label: CosmosDbMongoTreeDataProvider.CONTAINER_LABEL, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/cosmosDb.svg'), collapsibleState: TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServerContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/database/databaseTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/database/databaseTreeDataProvider.ts index 36d2e6ad4d..5b2b79ef68 100644 --- a/extensions/azurecore/src/azureResource/providers/database/databaseTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/database/databaseTreeDataProvider.ts @@ -17,7 +17,7 @@ import { AzureAccount, azureResource } from 'azurecore'; export class AzureResourceDatabaseTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.database.treeDataProvider.databaseContainer'; - private static readonly containerLabel = localize('azure.resource.providers.database.treeDataProvider.databaseContainerLabel', "SQL database"); + private static readonly containerLabel = localize('azure.resource.providers.database.treeDataProvider.databaseContainerLabel', "SQL databases"); public constructor( databaseService: azureResource.IAzureResourceService, @@ -28,12 +28,9 @@ export class AzureResourceDatabaseTreeDataProvider extends ResourceTreeDataProvi public getTreeItemForResource(database: azureResource.AzureResourceDatabase, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.databaseServer}${account.key.accountId}${database.serverFullName}.${AzureResourcePrefixes.database}${database.id ?? database.name}`, + id: `${AzureResourcePrefixes.database}${account.key.accountId}${database.tenant}${database.serverFullName}.${AzureResourcePrefixes.database}${database.id ?? database.name}`, label: this.browseConnectionMode ? `${database.serverName}/${database.name} (${AzureResourceDatabaseTreeDataProvider.containerLabel}, ${database.subscription.name})` : `${database.name} (${database.serverName})`, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_database_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_database.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/sqlDatabase.svg'), collapsibleState: this.browseConnectionMode ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.database, payload: { @@ -64,10 +61,7 @@ export class AzureResourceDatabaseTreeDataProvider extends ResourceTreeDataProvi return [{ id: AzureResourceDatabaseTreeDataProvider.containerId, label: AzureResourceDatabaseTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/sqlDatabase.svg'), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/databaseServer/databaseServerTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/databaseServer/databaseServerTreeDataProvider.ts index 9247b716aa..28c29a7d1f 100644 --- a/extensions/azurecore/src/azureResource/providers/databaseServer/databaseServerTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/databaseServer/databaseServerTreeDataProvider.ts @@ -16,7 +16,7 @@ import { AzureAccount, azureResource } from 'azurecore'; export class AzureResourceDatabaseServerTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.databaseServer.treeDataProvider.databaseServerContainer'; - private static readonly containerLabel = localize('azure.resource.providers.databaseServer.treeDataProvider.databaseServerContainerLabel', "SQL server"); + private static readonly containerLabel = localize('azure.resource.providers.databaseServer.treeDataProvider.databaseServerContainerLabel', "SQL servers"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -27,12 +27,9 @@ export class AzureResourceDatabaseServerTreeDataProvider extends ResourceTreeDat public getTreeItemForResource(databaseServer: azureResource.AzureResourceDatabaseServer, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.databaseServer}${account.key.accountId}${databaseServer.id ?? databaseServer.name}`, + id: `${AzureResourcePrefixes.databaseServer}${account.key.accountId}${databaseServer.tenant}${databaseServer.id ?? databaseServer.name}`, label: this.browseConnectionMode ? `${databaseServer.name} (${AzureResourceDatabaseServerTreeDataProvider.containerLabel}, ${databaseServer.subscription.name})` : databaseServer.name, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_server_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_server.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/sqlServer.svg'), collapsibleState: this.browseConnectionMode ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServer, payload: { @@ -63,10 +60,7 @@ export class AzureResourceDatabaseServerTreeDataProvider extends ResourceTreeDat return [{ id: AzureResourceDatabaseServerTreeDataProvider.containerId, label: AzureResourceDatabaseServerTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/sqlServer.svg'), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServerContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/kusto/kustoTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/kusto/kustoTreeDataProvider.ts index 3dacda4036..ec963aaccc 100644 --- a/extensions/azurecore/src/azureResource/providers/kusto/kustoTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/kusto/kustoTreeDataProvider.ts @@ -16,7 +16,7 @@ import { AzureAccount, azureResource } from 'azurecore'; export class KustoTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.KustoContainer'; - private static readonly containerLabel = localize('azure.resource.providers.KustoContainerLabel', "Azure Data Explorer Cluster"); + private static readonly containerLabel = localize('azure.resource.providers.KustoContainerLabel', "Azure Data Explorer Clusters"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -27,12 +27,9 @@ export class KustoTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.postgresArcServer.treeDataProvider.postgresServerContainer'; // allow-any-unicode-next-line - private static readonly containerLabel = localize('azure.resource.providers.postgresArcServer.treeDataProvider.postgresServerContainerLabel', "PostgreSQL Hyperscale – Azure Arc"); + private static readonly containerLabel = localize('azure.resource.providers.postgresArcServer.treeDataProvider.postgresServerContainerLabel', "PostgreSQL servers – Azure Arc"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -28,12 +28,9 @@ export class PostgresServerArcTreeDataProvider extends ResourceTreeDataProviderB public getTreeItemForResource(databaseServer: azureResource.AzureResourceDatabaseServer, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.postgresServerArc}${account.key.accountId}${databaseServer.id ?? databaseServer.name}`, + id: `${AzureResourcePrefixes.postgresServerArc}${account.key.accountId}${databaseServer.tenant}${databaseServer.id ?? databaseServer.name}`, label: this.browseConnectionMode ? `${databaseServer.name} (${PostgresServerArcTreeDataProvider.containerLabel}, ${databaseServer.subscription.name})` : databaseServer.name, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_server_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_server.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/azureArcPostgresServer.svg'), collapsibleState: this.browseConnectionMode ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServer, payload: { @@ -67,10 +64,7 @@ export class PostgresServerArcTreeDataProvider extends ResourceTreeDataProviderB return [{ id: PostgresServerArcTreeDataProvider.containerId, label: PostgresServerArcTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/azureArcPostgresServer.svg'), collapsibleState: TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServerContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/postgresFlexibleServer/postgresFlexibleServerTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/postgresFlexibleServer/postgresFlexibleServerTreeDataProvider.ts index e3cfb26f31..88f183d1b2 100644 --- a/extensions/azurecore/src/azureResource/providers/postgresFlexibleServer/postgresFlexibleServerTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/postgresFlexibleServer/postgresFlexibleServerTreeDataProvider.ts @@ -27,12 +27,9 @@ export class PostgresFlexibleServerTreeDataProvider extends ResourceTreeDataProv public getTreeItemForResource(databaseServer: azureResource.AzureResourceDatabaseServer, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.postgresFlexibleServer}${account.key.accountId}${databaseServer.id ?? databaseServer.name}`, + id: `${AzureResourcePrefixes.postgresFlexibleServer}${account.key.accountId}${databaseServer.tenant}${databaseServer.id ?? databaseServer.name}`, label: this.browseConnectionMode ? `${databaseServer.name} (${PostgresFlexibleServerTreeDataProvider.containerLabel}, ${databaseServer.subscription.name})` : databaseServer.name, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_server_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_server.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/postgresServer.svg'), collapsibleState: this.browseConnectionMode ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServer, payload: { @@ -66,10 +63,7 @@ export class PostgresFlexibleServerTreeDataProvider extends ResourceTreeDataProv return [{ id: PostgresFlexibleServerTreeDataProvider.containerId, label: PostgresFlexibleServerTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/postgresServer.svg'), collapsibleState: TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServerContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/postgresServer/postgresServerTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/postgresServer/postgresServerTreeDataProvider.ts index ad0839eb55..baa4f77957 100644 --- a/extensions/azurecore/src/azureResource/providers/postgresServer/postgresServerTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/postgresServer/postgresServerTreeDataProvider.ts @@ -27,11 +27,11 @@ export class PostgresServerTreeDataProvider extends ResourceTreeDataProviderBase public getTreeItemForResource(databaseServer: azureResource.AzureResourceDatabaseServer, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.postgresServer}${account.key.accountId}${databaseServer.id ?? databaseServer.name}`, + id: `${AzureResourcePrefixes.postgresServer}${account.key.accountId}${databaseServer.tenant}${databaseServer.id ?? databaseServer.name}`, label: this.browseConnectionMode ? `${databaseServer.name} (${PostgresServerTreeDataProvider.containerLabel}, ${databaseServer.subscription.name})` : databaseServer.name, iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_server_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_server.svg') + dark: this._extensionContext.asAbsolutePath('resources/postgresServer.svg'), + light: this._extensionContext.asAbsolutePath('resources/postgresServer.svg') }, collapsibleState: this.browseConnectionMode ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServer, @@ -67,8 +67,8 @@ export class PostgresServerTreeDataProvider extends ResourceTreeDataProviderBase id: PostgresServerTreeDataProvider.containerId, label: PostgresServerTreeDataProvider.containerLabel, iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') + dark: this._extensionContext.asAbsolutePath('resources/postgresServer.svg'), + light: this._extensionContext.asAbsolutePath('resources/postgresServer.svg') }, collapsibleState: TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServerContainer diff --git a/extensions/azurecore/src/azureResource/providers/sqlinstance/sqlInstanceTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/sqlinstance/sqlInstanceTreeDataProvider.ts index fdf94d12d1..2090f4579e 100644 --- a/extensions/azurecore/src/azureResource/providers/sqlinstance/sqlInstanceTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/sqlinstance/sqlInstanceTreeDataProvider.ts @@ -16,7 +16,7 @@ import { AzureAccount, azureResource } from 'azurecore'; export class SqlInstanceTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.sqlInstanceContainer'; - private static readonly containerLabel = localize('azure.resource.providers.sqlInstanceContainerLabel', "Azure SQL DB managed instance"); + private static readonly containerLabel = localize('azure.resource.providers.sqlInstanceContainerLabel', "SQL managed instances"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -27,12 +27,9 @@ export class SqlInstanceTreeDataProvider extends ResourceTreeDataProviderBase { private static readonly containerId = 'azure.resource.providers.sqlInstanceArcContainer'; // allow-any-unicode-next-line - private static readonly containerLabel = localize('azure.resource.providers.sqlInstanceArcContainerLabel', "SQL managed instance – Azure Arc"); + private static readonly containerLabel = localize('azure.resource.providers.sqlInstanceArcContainerLabel', "SQL managed instances - Azure Arc"); public constructor( databaseServerService: azureResource.IAzureResourceService, @@ -28,12 +28,9 @@ export class SqlInstanceArcTreeDataProvider extends ResourceTreeDataProviderBase public getTreeItemForResource(databaseServer: azureResource.AzureResourceDatabaseServer, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.sqlInstanceArc}${account.key.accountId}${databaseServer.id ?? databaseServer.name}`, + id: `${AzureResourcePrefixes.sqlInstanceArc}${account.key.accountId}${databaseServer.tenant}${databaseServer.id ?? databaseServer.name}`, label: this.browseConnectionMode ? `${databaseServer.name} (${SqlInstanceArcTreeDataProvider.containerLabel}, ${databaseServer.subscription.name})` : databaseServer.name, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_instance_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_instance.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/azureArcSqlManagedInstance.svg'), collapsibleState: this.browseConnectionMode ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServer, payload: { @@ -64,10 +61,7 @@ export class SqlInstanceArcTreeDataProvider extends ResourceTreeDataProviderBase return [{ id: SqlInstanceArcTreeDataProvider.containerId, label: SqlInstanceArcTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/azureArcSqlManagedInstance.svg'), collapsibleState: TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.databaseServerContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/synapseSqlPool/synapseSqlPoolTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/synapseSqlPool/synapseSqlPoolTreeDataProvider.ts index e3cc90330e..3cfb6164bf 100644 --- a/extensions/azurecore/src/azureResource/providers/synapseSqlPool/synapseSqlPoolTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/synapseSqlPool/synapseSqlPoolTreeDataProvider.ts @@ -28,12 +28,9 @@ export class AzureResourceSynapseSqlPoolTreeDataProvider extends ResourceTreeDat public getTreeItemForResource(synapse: azureResource.AzureResourceDatabase, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.synapseWorkspace}${account.key.accountId}${synapse.serverFullName}.${AzureResourcePrefixes.synapseSqlPool}${synapse.id ?? synapse.name}`, + id: `${AzureResourcePrefixes.synapseWorkspace}${account.key.accountId}${synapse.tenant}${synapse.serverFullName}.${AzureResourcePrefixes.synapseSqlPool}${synapse.id ?? synapse.name}`, label: this.browseConnectionMode ? `${synapse.serverName}/${synapse.name} (${AzureResourceSynapseSqlPoolTreeDataProvider.containerLabel}, ${synapse.subscription.name})` : `${synapse.name} (${synapse.serverName})`, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_database_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_database.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/sqlPools.svg'), collapsibleState: this.browseConnectionMode ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.synapseSqlPool, payload: { @@ -64,10 +61,7 @@ export class AzureResourceSynapseSqlPoolTreeDataProvider extends ResourceTreeDat return [{ id: AzureResourceSynapseSqlPoolTreeDataProvider.containerId, label: AzureResourceSynapseSqlPoolTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/sqlPools.svg'), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.synapseSqlPoolContainer }]; diff --git a/extensions/azurecore/src/azureResource/providers/synapseWorkspace/synapseWorkspaceTreeDataProvider.ts b/extensions/azurecore/src/azureResource/providers/synapseWorkspace/synapseWorkspaceTreeDataProvider.ts index 1b4671c007..f6b4443bca 100644 --- a/extensions/azurecore/src/azureResource/providers/synapseWorkspace/synapseWorkspaceTreeDataProvider.ts +++ b/extensions/azurecore/src/azureResource/providers/synapseWorkspace/synapseWorkspaceTreeDataProvider.ts @@ -27,12 +27,9 @@ export class AzureResourceSynapseWorkspaceTreeDataProvider extends ResourceTreeD public getTreeItemForResource(synapseWorkspace: azureResource.AzureResourceDatabaseServer, account: AzureAccount): TreeItem { return { - id: `${AzureResourcePrefixes.synapseWorkspace}${account.key.accountId}${synapseWorkspace.id ?? synapseWorkspace.name}`, + id: `${AzureResourcePrefixes.synapseWorkspace}${account.key.accountId}${synapseWorkspace.tenant}${synapseWorkspace.id ?? synapseWorkspace.name}`, label: this.browseConnectionMode ? `${synapseWorkspace.name} (${AzureResourceSynapseWorkspaceTreeDataProvider.containerLabel}, ${synapseWorkspace.subscription.name})` : synapseWorkspace.name, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/sql_server_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/sql_server.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/azureSynapseAnalytics.svg'), collapsibleState: this.browseConnectionMode ? vscode.TreeItemCollapsibleState.None : vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.synapseWorkspace, payload: { @@ -63,10 +60,7 @@ export class AzureResourceSynapseWorkspaceTreeDataProvider extends ResourceTreeD return [{ id: AzureResourceSynapseWorkspaceTreeDataProvider.containerId, label: AzureResourceSynapseWorkspaceTreeDataProvider.containerLabel, - iconPath: { - dark: this._extensionContext.asAbsolutePath('resources/dark/folder_inverse.svg'), - light: this._extensionContext.asAbsolutePath('resources/light/folder.svg') - }, + iconPath: this._extensionContext.asAbsolutePath('resources/azureSynapseAnalytics.svg'), collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, contextValue: AzureResourceItemType.synapseWorkspaceContainer }]; diff --git a/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts b/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts index eda2af3708..763f506fae 100644 --- a/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts +++ b/extensions/azurecore/src/azureResource/services/subscriptionFilterService.ts @@ -3,11 +3,11 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureAccount, azureResource } from 'azurecore'; +import { AzureAccount, Tenant, azureResource } from 'azurecore'; import { IAzureResourceSubscriptionFilterService, IAzureResourceCacheService } from '../interfaces'; interface AzureResourceSelectedSubscriptionsCache { - selectedSubscriptions: { [accountId: string]: azureResource.AzureResourceSubscription[] }; + selectedSubscriptions: { [accountTenantId: string]: azureResource.AzureResourceSubscription[] }; } export class AzureResourceSubscriptionFilterService implements IAzureResourceSubscriptionFilterService { @@ -19,36 +19,44 @@ export class AzureResourceSubscriptionFilterService implements IAzureResourceSub this._cacheKey = this._cacheService.generateKey('selectedSubscriptions'); } - public async getSelectedSubscriptions(account: AzureAccount): Promise { + public async getSelectedSubscriptions(account: AzureAccount, tenant: Tenant): Promise { let selectedSubscriptions: azureResource.AzureResourceSubscription[] = []; const cache = this._cacheService.get(this._cacheKey); if (cache) { - selectedSubscriptions = cache.selectedSubscriptions[account.key.accountId]; + selectedSubscriptions = cache.selectedSubscriptions[account.key.accountId + '/' + tenant.id]; + if (!selectedSubscriptions) { + let oldTenantCache = cache.selectedSubscriptions[account.key.accountId]?.filter(sub => sub.tenant === tenant.id); + if (oldTenantCache) { + await this.saveSelectedSubscriptions(account, tenant, oldTenantCache); + } + } } - return selectedSubscriptions; } - public async saveSelectedSubscriptions(account: AzureAccount, selectedSubscriptions: azureResource.AzureResourceSubscription[]): Promise { - let selectedSubscriptionsCache: { [accountId: string]: azureResource.AzureResourceSubscription[] } = {}; + public async saveSelectedSubscriptions(account: AzureAccount, tenant: Tenant, selectedSubscriptions: azureResource.AzureResourceSubscription[]): Promise { + let selections: { [accountTenantId: string]: azureResource.AzureResourceSubscription[] } = {}; const cache = this._cacheService.get(this._cacheKey); if (cache) { - selectedSubscriptionsCache = cache.selectedSubscriptions; + selections = cache.selectedSubscriptions; } - if (!selectedSubscriptionsCache) { - selectedSubscriptionsCache = {}; + if (!selections) { + selections = {}; } - selectedSubscriptionsCache[account.key.accountId] = selectedSubscriptions; + let accountTenantId = account.key.accountId; + if (tenant) { + accountTenantId += '/' + tenant.id; + } - await this._cacheService.update(this._cacheKey, { selectedSubscriptions: selectedSubscriptionsCache }); + selections[accountTenantId] = selectedSubscriptions; + + await this._cacheService.update(this._cacheKey, { selectedSubscriptions: selections }); const filters: string[] = []; - for (const accountId in selectedSubscriptionsCache) { - filters.push(...selectedSubscriptionsCache[accountId].map((subscription) => `${accountId}/${subscription.id}/${subscription.name}`)); - } + filters.push(...selections[accountTenantId].map((subscription) => `${accountTenantId}/${subscription.id}`)); } } diff --git a/extensions/azurecore/src/azureResource/services/tenantFilterService.ts b/extensions/azurecore/src/azureResource/services/tenantFilterService.ts new file mode 100644 index 0000000000..ff3ad0292e --- /dev/null +++ b/extensions/azurecore/src/azureResource/services/tenantFilterService.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureAccount, Tenant } from 'azurecore'; +import { IAzureResourceTenantFilterService, IAzureResourceCacheService } from '../interfaces'; + +interface AzureResourceSelectedTenantsCache { + selectedTenants: { [accountId: string]: Tenant[] }; +} + +export class AzureResourceTenantFilterService implements IAzureResourceTenantFilterService { + private _cacheKey: string; + + public constructor( + private _cacheService: IAzureResourceCacheService + ) { + this._cacheKey = this._cacheService.generateKey('selectedTenants'); + } + + public async getSelectedTenants(account: AzureAccount): Promise { + let selectedTenants: Tenant[] = []; + + const cache = this._cacheService.get(this._cacheKey); + if (cache) { + selectedTenants = cache.selectedTenants[account.key.accountId]; + } + + return selectedTenants; + } + + public async saveSelectedTenants(account: AzureAccount, selectedTenants: Tenant[]): Promise { + let selectedTenantsCache: { [accountId: string]: Tenant[] } = {}; + + const cache = this._cacheService.get(this._cacheKey); + if (cache) { + selectedTenantsCache = cache.selectedTenants; + } + + if (!selectedTenantsCache) { + selectedTenantsCache = {}; + } + + selectedTenantsCache[account.key.accountId] = selectedTenants; + + await this._cacheService.update(this._cacheKey, { selectedTenants: selectedTenantsCache }); + + const filters: string[] = []; + for (const accountId in selectedTenantsCache) { + filters.push(...selectedTenantsCache[accountId].map((tenant) => `${accountId}/${tenant.id}/${tenant.displayName}`)); + } + } +} diff --git a/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts b/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts index 272808473f..2835e8793b 100644 --- a/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts +++ b/extensions/azurecore/src/azureResource/tree/accountTreeNode.ts @@ -11,16 +11,13 @@ const localize = nls.loadMessageBundle(); import { AppContext } from '../../appContext'; import { TreeNode } from '../treeNode'; -import { AzureSubscriptionError } from '../errors'; import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; import { AzureResourceItemType, AzureResourceServiceNames } from '../constants'; -import { AzureResourceSubscriptionTreeNode } from './subscriptionTreeNode'; -import { AzureResourceMessageTreeNode } from '../messageTreeNode'; -import { AzureResourceErrorMessageUtil } from '../utils'; import { IAzureResourceTreeChangeHandler } from './treeChangeHandler'; -import { IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService } from '../../azureResource/interfaces'; -import { AzureAccount, azureResource } from 'azurecore'; -import { TenantIgnoredError } from '../../utils/TenantIgnoredError'; +import { AzureAccount, Tenant } from 'azurecore'; +import { AzureResourceTenantTreeNode } from './tenantTreeNode'; +import { AzureResourceMessageTreeNode } from '../messageTreeNode'; +import { IAzureResourceTenantFilterService } from '../interfaces'; export class AzureResourceAccountTreeNode extends AzureResourceContainerTreeNodeBase { public constructor( @@ -29,92 +26,51 @@ export class AzureResourceAccountTreeNode extends AzureResourceContainerTreeNode treeChangeHandler: IAzureResourceTreeChangeHandler ) { super(appContext, treeChangeHandler, undefined); + this._tenantFilterService = this.appContext.getService(AzureResourceServiceNames.tenantFilterService); - this._subscriptionService = this.appContext.getService(AzureResourceServiceNames.subscriptionService); - this._subscriptionFilterService = this.appContext.getService(AzureResourceServiceNames.subscriptionFilterService); - + if (this.account.properties.tenants.length === 1) { + this._singleTenantTreeNode = new AzureResourceTenantTreeNode(this.account, this.account.properties.tenants[0], this, this.appContext, this.treeChangeHandler); + } this._id = `account_${this.account.key.accountId}`; - this.setCacheKey(`${this._id}.subscriptions`); + this.setCacheKey(`${this._id}.tenants`); this._label = this.generateLabel(); } public async getChildren(): Promise { - try { - let subscriptions: azureResource.AzureResourceSubscription[] = []; + let tenants = this.account.properties.tenants; + this._totalTenantsCount = tenants.length; - if (this._isClearingCache) { - subscriptions = await this._subscriptionService.getSubscriptions(this.account); - await this.updateCache(subscriptions); - this._isClearingCache = false; - } else { - subscriptions = await this.getCachedSubscriptions(); - } + const selectedTenants = await this._tenantFilterService.getSelectedTenants(this.account); + const selectedTenantIds = (selectedTenants || []).map((Tenant) => Tenant.id); - this._totalSubscriptionCount = subscriptions.length; - - const selectedSubscriptions = await this._subscriptionFilterService.getSelectedSubscriptions(this.account); - const selectedSubscriptionIds = (selectedSubscriptions || []).map((subscription) => subscription.id); - if (selectedSubscriptionIds.length > 0) { - subscriptions = subscriptions.filter((subscription) => selectedSubscriptionIds.indexOf(subscription.id) !== -1); - this._selectedSubscriptionCount = selectedSubscriptionIds.length; - } else { - // ALL subscriptions are listed by default - this._selectedSubscriptionCount = this._totalSubscriptionCount; - } - - this.refreshLabel(); - - if (subscriptions.length === 0) { - return [AzureResourceMessageTreeNode.create(AzureResourceAccountTreeNode.noSubscriptionsLabel, this)]; - } else { - const authLibrary = vscode.workspace.getConfiguration('azure').get('authenticationLibrary'); - if (authLibrary === 'ADAL') { - // Filter out everything that we can't authenticate to. - const hasTokenResults = await Promise.all(subscriptions.map(async s => { - let token: azdata.accounts.AccountSecurityToken | undefined = undefined; - let errMsg = ''; - try { - token = await azdata.accounts.getAccountSecurityToken(this.account, s.tenant!, azdata.AzureResource.ResourceManagement); - } catch (err) { - if (!(err instanceof TenantIgnoredError)) { - errMsg = AzureResourceErrorMessageUtil.getErrorMessage(err); - } - } - if (!token) { - if (errMsg !== '') { - void vscode.window.showWarningMessage(localize('azure.unableToAccessSubscription', "Unable to access subscription {0} ({1}). Please [refresh the account](command:azure.resource.signin) to try again. {2}", s.name, s.id, errMsg)); - } - return false; - } - return true; - })); - subscriptions = subscriptions.filter((_s, i) => hasTokenResults[i]); - } - let subTreeNodes = await Promise.all(subscriptions.map(async (subscription) => { - return new AzureResourceSubscriptionTreeNode(this.account, subscription, subscription.tenant!, this.appContext, this.treeChangeHandler, this); - })); - return subTreeNodes.sort((a, b) => a.subscription.name.localeCompare(b.subscription.name)); - } - } catch (error) { - if (error instanceof AzureSubscriptionError) { - void vscode.commands.executeCommand('azure.resource.signin'); - } - return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), this)]; + if (selectedTenantIds.length > 0) { + tenants = tenants.filter((tenant) => selectedTenantIds.indexOf(tenant.id) !== -1); + this._selectedTenantsCount = selectedTenantIds.length; + } else { + // ALL Tenants are listed by default + this._selectedTenantsCount = this._totalTenantsCount; } - } - public async getCachedSubscriptions(): Promise { - return this.getCache() ?? []; + this.refreshLabel(); + + if (this.totalTenantsCount === 1) { + return await this._singleTenantTreeNode?.getChildren() ?? []; + } else if (tenants.length === 0) { + return [AzureResourceMessageTreeNode.create(AzureResourceAccountTreeNode.noTenantsLabel, this)]; + } else { + let subTreeNodes = await Promise.all(tenants.map(async (tenant) => { + return new AzureResourceTenantTreeNode(this.account, tenant, this, this.appContext, this.treeChangeHandler); + })); + return subTreeNodes.sort((a, b) => a.tenant.displayName.localeCompare(b.tenant.displayName)); + } } public getTreeItem(): vscode.TreeItem | Promise { const item = new vscode.TreeItem(this._label, vscode.TreeItemCollapsibleState.Collapsed); item.id = this._id; - item.contextValue = AzureResourceItemType.account; - item.iconPath = { - dark: this.appContext.extensionContext.asAbsolutePath('resources/dark/account_inverse.svg'), - light: this.appContext.extensionContext.asAbsolutePath('resources/light/account.svg') - }; + item.contextValue = this.account.properties.tenants.length > 1 ? + AzureResourceItemType.multipleTenantAccount : AzureResourceItemType.singleTenantAccount; + item.iconPath = this.appContext.extensionContext.asAbsolutePath('resources/users.svg'); return item; } @@ -137,12 +93,12 @@ export class AzureResourceAccountTreeNode extends AzureResourceContainerTreeNode return this._id; } - public get totalSubscriptionCount(): number { - return this._totalSubscriptionCount; + public get totalTenantsCount(): number { + return this._totalTenantsCount; } - public get selectedSubscriptionCount(): number { - return this._selectedSubscriptionCount; + public get selectedTenantCount(): number { + return this._selectedTenantsCount; } protected refreshLabel(): void { @@ -156,25 +112,23 @@ export class AzureResourceAccountTreeNode extends AzureResourceContainerTreeNode private generateLabel(): string { let label = this.account.displayInfo.displayName; - if (this._totalSubscriptionCount !== 0) { - label += ` (${this._selectedSubscriptionCount} / ${this._totalSubscriptionCount} subscriptions)`; + if (this._totalTenantsCount === 1 && this._singleTenantTreeNode) { + label += ` (${this._singleTenantTreeNode.selectedSubscriptionCount} / ${this._singleTenantTreeNode.totalSubscriptionCount} subscriptions)`; + } else if (this._totalTenantsCount > 0) { + label += ` (${this._selectedTenantsCount} / ${this._totalTenantsCount} tenants)`; } return label; } - private _subscriptionService: IAzureResourceSubscriptionService; - private _subscriptionFilterService: IAzureResourceSubscriptionFilterService; + private _tenantFilterService: IAzureResourceTenantFilterService; private _id: string; private _label: string; - private _totalSubscriptionCount = 0; - private _selectedSubscriptionCount = 0; + private _totalTenantsCount = 0; + private _selectedTenantsCount = 0; + private _singleTenantTreeNode: AzureResourceTenantTreeNode | undefined; - private static readonly noSubscriptionsLabel = localize('azure.resource.tree.accountTreeNode.noSubscriptionsLabel', "No Subscriptions found."); - - sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); - } + private static readonly noTenantsLabel = localize('azure.resource.tree.accountTreeNode.noTenantsLabel', "No Tenants found."); } diff --git a/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts b/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts index 9312feab4f..d6c761a729 100644 --- a/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts +++ b/extensions/azurecore/src/azureResource/tree/baseTreeNodes.ts @@ -17,7 +17,6 @@ abstract class AzureResourceTreeNodeBase extends TreeNode { parent: TreeNode | undefined ) { super(); - this.parent = parent; } } @@ -29,7 +28,6 @@ export abstract class AzureResourceContainerTreeNodeBase extends AzureResourceTr parent: TreeNode | undefined ) { super(appContext, treeChangeHandler, parent); - this._cacheService = this.appContext.getService(AzureResourceServiceNames.cacheService); } diff --git a/extensions/azurecore/src/azureResource/tree/connectionDialogTreeProvider.ts b/extensions/azurecore/src/azureResource/tree/connectionDialogTreeProvider.ts index 50fd13f7e4..e96f5caa05 100644 --- a/extensions/azurecore/src/azureResource/tree/connectionDialogTreeProvider.ts +++ b/extensions/azurecore/src/azureResource/tree/connectionDialogTreeProvider.ts @@ -15,9 +15,9 @@ import { AzureResourceMessageTreeNode } from '../messageTreeNode'; import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; import { AzureResourceErrorMessageUtil, equals, filterAccounts } from '../utils'; import { IAzureResourceTreeChangeHandler } from './treeChangeHandler'; -import { FlatAccountTreeNode } from './flatAccountTreeNode'; import { Logger } from '../../utils/Logger'; import { AzureAccount } from 'azurecore'; +import { FlatAccountTreeNode } from './flatAccountTreeNode'; export class ConnectionDialogTreeProvider implements vscode.TreeDataProvider, IAzureResourceTreeChangeHandler { public isSystemInitialized: boolean = false; @@ -45,7 +45,7 @@ export class ConnectionDialogTreeProvider implements vscode.TreeDataProvider { if (element) { - return element.getChildren(true); + return element.getChildren(); } if (!this.isSystemInitialized) { @@ -63,6 +63,7 @@ export class ConnectionDialogTreeProvider implements vscode.TreeDataProvider(AzureResourceServiceNames.subscriptionService); - this._subscriptionFilterService = this.appContext.getService(AzureResourceServiceNames.subscriptionFilterService); - this._resourceService = this.appContext.getService(AzureResourceServiceNames.resourceService); + this._tenantFilterService = this.appContext.getService(AzureResourceServiceNames.tenantFilterService); this._id = `account_${this.account.key.accountId}`; - this.setCacheKey(`${this._id}.dataresources`); - this._label = account.displayInfo.displayName; - this._loader = new FlatAccountTreeNodeLoader(appContext, this._resourceService, this._subscriptionService, this._subscriptionFilterService, this.account, this); - this._loader.onNewResourcesAvailable(() => { - this.treeChangeHandler.notifyNodeChanged(this); - }); - - this._loader.onLoadingStatusChanged(() => { - this.treeChangeHandler.notifyNodeChanged(this); - }); - } - - public async updateLabel(): Promise { - const subscriptionInfo = await getSubscriptionInfo(this.account, this._subscriptionService, this._subscriptionFilterService); - if (this._loader.isLoading) { - this._label = localize('azure.resource.tree.accountTreeNode.titleLoading', "{0} - Loading...", this.account.displayInfo.displayName); - } else if (subscriptionInfo.total !== 0) { - this._label = localize({ - key: 'azure.resource.tree.accountTreeNode.title', - comment: [ - '{0} is the display name of the azure account', - '{1} is the number of selected subscriptions in this account', - '{2} is the number of total subscriptions in this account' - ] - }, "{0} ({1}/{2} subscriptions)", this.account.displayInfo.displayName, subscriptionInfo.selected, subscriptionInfo.total); - } else { - this._label = this.account.displayInfo.displayName; + this.setCacheKey(`${this._id}.tenants`); + if (this.account.properties.tenants.length === 1) { + this._singleTenantTreeNode = new FlatTenantTreeNode(this.account, this.account.properties.tenants[0], this, this.appContext, this.treeChangeHandler); } + this._label = this.generateLabel(); } public async getChildren(): Promise { + let nodesResult: TreeNode[] = []; + let tenants = this.account.properties.tenants; + this._totalTenantsCount = tenants.length; + + const selectedTenants = await this._tenantFilterService.getSelectedTenants(this.account); + const selectedTenantIds = (selectedTenants || []).map((Tenant) => Tenant.id); + + if (selectedTenantIds.length > 0) { + tenants = tenants.filter((tenant) => selectedTenantIds.indexOf(tenant.id) !== -1); + this._selectedTenantsCount = selectedTenantIds.length; + } else { + // ALL Tenants are listed by default + this._selectedTenantsCount = this._totalTenantsCount; + } + + this.refreshLabel(); + + if (this._totalTenantsCount === 1) { + if (this._isClearingCache) { + await this._singleTenantTreeNode?.getChildren(); + } else { + nodesResult = await this._singleTenantTreeNode?.getChildren() ?? []; + } + } else if (tenants.length === 0) { + nodesResult = [AzureResourceMessageTreeNode.create(FlatAccountTreeNode.noTenantsLabel, this)]; + } else { + if (!this._multiTenantTreeNodes) { + this._multiTenantTreeNodes = await Promise.all(tenants.map(async (tenant) => { + let node = new FlatTenantTreeNode(this.account, tenant, undefined, this.appContext, this.treeChangeHandler); + return node; + })); + } + nodesResult = this._multiTenantTreeNodes.sort((a, b) => a.tenant.displayName.localeCompare(b.tenant.displayName)); + } + if (this._isClearingCache) { - await this.updateLabel(); - this._loader.start().catch(err => console.error('Error loading Azure FlatAccountTreeNodes ', err)); + this._multiTenantTreeNodes?.forEach(node => { + node.clearCache(); + }); this._isClearingCache = false; return []; } else { - return this._loader.nodes; + return nodesResult; } } public getTreeItem(): vscode.TreeItem | Promise { const item = new vscode.TreeItem(this._label, vscode.TreeItemCollapsibleState.Collapsed); item.id = this._id; - item.contextValue = AzureResourceItemType.account; - item.iconPath = { - dark: this.appContext.extensionContext.asAbsolutePath('resources/dark/account_inverse.svg'), - light: this.appContext.extensionContext.asAbsolutePath('resources/light/account.svg') - }; + item.contextValue = this.account.properties.tenants.length > 1 ? + AzureResourceItemType.multipleTenantAccount : AzureResourceItemType.singleTenantAccount; + item.iconPath = this.appContext.extensionContext.asAbsolutePath('resources/users.svg'); return item; } @@ -107,130 +112,34 @@ export class FlatAccountTreeNode extends AzureResourceContainerTreeNodeBase { return this._id; } - private _subscriptionService: IAzureResourceSubscriptionService; - private _subscriptionFilterService: IAzureResourceSubscriptionFilterService; - private _resourceService: AzureResourceService; - private _loader: FlatAccountTreeNodeLoader; + public refreshLabel(): void { + const newLabel = this.generateLabel(); + if (this._label !== newLabel) { + this._label = newLabel; + this.treeChangeHandler.notifyNodeChanged(this); + } + } + + private generateLabel(): string { + let label = this.account.displayInfo.displayName; + + if (this._totalTenantsCount === 1 && this._singleTenantTreeNode) { + label += ` (${this._singleTenantTreeNode._selectedSubscriptionCount} / ${this._singleTenantTreeNode._totalSubscriptionCount} subscriptions)`; + } else if (this._totalTenantsCount > 0) { + label += ` (${this._selectedTenantsCount} / ${this._totalTenantsCount} tenants)`; + } + + return label; + } + + private _tenantFilterService: IAzureResourceTenantFilterService; + private _id: string; private _label: string; -} - -async function getSubscriptionInfo(account: AzureAccount, subscriptionService: IAzureResourceSubscriptionService, subscriptionFilterService: IAzureResourceSubscriptionFilterService): Promise<{ - subscriptions: azureResource.AzureResourceSubscription[], - total: number, - selected: number -}> { - let subscriptions = await subscriptionService.getSubscriptions(account); - const total = subscriptions.length; - let selected = total; - - const selectedSubscriptions = await subscriptionFilterService.getSelectedSubscriptions(account); - const selectedSubscriptionIds = (selectedSubscriptions || []).map((subscription) => subscription.id); - if (selectedSubscriptionIds.length > 0) { - subscriptions = subscriptions.filter((subscription) => selectedSubscriptionIds.indexOf(subscription.id) !== -1); - selected = selectedSubscriptionIds.length; - } - return { - subscriptions, - total, - selected - }; -} -class FlatAccountTreeNodeLoader { - - private _isLoading: boolean = false; - private _nodes: TreeNode[] = []; - private readonly _onNewResourcesAvailable = new vscode.EventEmitter(); - public readonly onNewResourcesAvailable = this._onNewResourcesAvailable.event; - private readonly _onLoadingStatusChanged = new vscode.EventEmitter(); - public readonly onLoadingStatusChanged = this._onLoadingStatusChanged.event; - - constructor(private readonly appContext: AppContext, - private readonly _resourceService: AzureResourceService, - private readonly _subscriptionService: IAzureResourceSubscriptionService, - private readonly _subscriptionFilterService: IAzureResourceSubscriptionFilterService, - private readonly _account: AzureAccount, - private readonly _accountNode: TreeNode) { - } - - public get isLoading(): boolean { - return this._isLoading; - } - - public get nodes(): TreeNode[] { - return this._nodes; - } - - public async start(): Promise { - if (this._isLoading) { - return; - } - this._isLoading = true; - this._nodes = []; - this._onLoadingStatusChanged.fire(); - let newNodesAvailable = false; - - // Throttle the refresh events to at most once per 500ms - const refreshHandle = setInterval(() => { - if (newNodesAvailable) { - this._onNewResourcesAvailable.fire(); - newNodesAvailable = false; - } - if (!this.isLoading) { - clearInterval(refreshHandle); - } - }, 500); - try { - - // Authenticate to tenants to filter out subscriptions that are not accessible. - let tenants = this._account.properties.tenants; - // Filter out tenants that we can't authenticate to. - tenants = tenants.filter(async tenant => { - try { - const token = await azdata.accounts.getAccountSecurityToken(this._account, tenant.id, azdata.AzureResource.ResourceManagement); - return token !== undefined; - } catch (e) { - return false; - } - }); - - let subscriptions: azureResource.AzureResourceSubscription[] = (await getSubscriptionInfo(this._account, this._subscriptionService, this._subscriptionFilterService)).subscriptions; - - if (subscriptions.length !== 0) { - // Filter out subscriptions that don't belong to the tenants we filtered above. - subscriptions = subscriptions.filter(async s => { - const tenant = tenants.find(t => t.id === s.tenant); - if (!tenant) { - Logger.info(`Account does not have permissions to view subscription ${JSON.stringify(s)}.`); - return false; - } - return true; - }); - } - - const resources = await this._resourceService.getAllChildren(this._account, subscriptions, true); - if (resources?.length > 0) { - this._nodes.push(...resources.map(dr => new AzureResourceResourceTreeNode(dr, this._accountNode, this.appContext))); - this._nodes = this.nodes.sort((a, b) => { - return a.getNodeInfo().label.localeCompare(b.getNodeInfo().label); - }); - newNodesAvailable = true; - } - // Create "No Resources Found" message node if no resources found under azure account. - if (this._nodes.length === 0) { - this._nodes.push(AzureResourceMessageTreeNode.create(localize('azure.resource.flatAccountTreeNode.noResourcesLabel', "No Resources found."), this._accountNode)) - } - } catch (error) { - if (error instanceof AzureSubscriptionError) { - void vscode.commands.executeCommand('azure.resource.signin'); - } - // http status code 429 means "too many requests" - // use a custom error message for azure resource graph api throttling error to make it more actionable for users. - const errorMessage = error?.statusCode === 429 ? localize('azure.resource.throttleerror', "Requests from this account have been throttled. To retry, please select a smaller number of subscriptions.") : AzureResourceErrorMessageUtil.getErrorMessage(error); - void vscode.window.showErrorMessage(localize('azure.resource.tree.loadresourceerror', "An error occurred while loading Azure resources: {0}", errorMessage)); - } - - this._isLoading = false; - this._onLoadingStatusChanged.fire(); - } + private _totalTenantsCount = 0; + private _selectedTenantsCount = 0; + private _singleTenantTreeNode: FlatTenantTreeNode | undefined; + private _multiTenantTreeNodes: FlatTenantTreeNode[] | undefined; + + private static readonly noTenantsLabel = localize('azure.resource.tree.accountTreeNode.noTenantsLabel', "No Tenants found."); } diff --git a/extensions/azurecore/src/azureResource/tree/flatTenantTreeNode.ts b/extensions/azurecore/src/azureResource/tree/flatTenantTreeNode.ts new file mode 100644 index 0000000000..f221ea3bb1 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/flatTenantTreeNode.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AppContext } from '../../appContext'; +import { TreeNode } from '../treeNode'; +import { AzureSubscriptionError } from '../errors'; +import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType, AzureResourceServiceNames } from '../constants'; +import { IAzureResourceTreeChangeHandler } from './treeChangeHandler'; +import { IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService } from '../interfaces'; +import { AzureAccount, Tenant, azureResource } from 'azurecore'; +import { AzureResourceService } from '../resourceService'; +import { AzureResourceResourceTreeNode } from '../resourceTreeNode'; +import { AzureResourceErrorMessageUtil } from '../utils'; +import { AzureResourceMessageTreeNode } from '../messageTreeNode'; +import { FlatAccountTreeNode } from './flatAccountTreeNode'; + +export class FlatTenantTreeNode extends AzureResourceContainerTreeNodeBase { + public constructor( + public readonly account: AzureAccount, + public readonly tenant: Tenant, + private readonly parentNode: FlatAccountTreeNode | undefined, + appContext: AppContext, + treeChangeHandler: IAzureResourceTreeChangeHandler, + ) { + super(appContext, treeChangeHandler, parentNode); + + this._subscriptionService = this.appContext.getService(AzureResourceServiceNames.subscriptionService); + this._subscriptionFilterService = this.appContext.getService(AzureResourceServiceNames.subscriptionFilterService); + this._resourceService = this.appContext.getService(AzureResourceServiceNames.resourceService); + + this._id = `account_${this.account.key.accountId}.tenant_${tenant.id}`; + this.setCacheKey(`${this._id}.dataresources`); + this._label = this.generateLabel(); + } + + public async getChildren(): Promise { + try { + let nodesResult: TreeNode[] = []; + let subscriptions: azureResource.AzureResourceSubscription[] = []; + if (this._isClearingCache) { + subscriptions = await this._subscriptionService.getSubscriptions(this.account, [this.tenant.id]); + await this.updateCache(subscriptions); + } else { + subscriptions = await this.getCachedSubscriptions(); + } + + this._totalSubscriptionCount = subscriptions.length; + + const allSubscriptions = await this._subscriptionFilterService.getSelectedSubscriptions(this.account, this.tenant); + const selectedSubscriptions = allSubscriptions?.filter(subscription => subscription.tenant === this.tenant.id); + const selectedSubscriptionIds = (selectedSubscriptions || []).map((subscription) => subscription.id); + if (selectedSubscriptionIds.length > 0) { + subscriptions = subscriptions.filter((subscription) => selectedSubscriptionIds.indexOf(subscription.id) !== -1); + this._selectedSubscriptionCount = selectedSubscriptionIds.length; + } else { + // ALL subscriptions are listed by default + this._selectedSubscriptionCount = this._totalSubscriptionCount; + } + + this.refreshLabel(); + + if (this._isClearingCache) { + this._isClearingCache = false; + return []; + } + + if (subscriptions.length === 0) { + nodesResult = [AzureResourceMessageTreeNode.create(FlatTenantTreeNode.noSubscriptionsLabel, this)]; + } else { + let _nodes: AzureResourceResourceTreeNode[] = []; + const resources = await this._resourceService.getAllChildren(this.account, subscriptions, true); + if (resources?.length > 0) { + _nodes.push(...resources.map(dr => new AzureResourceResourceTreeNode(dr, this.parentNode ?? this, this.appContext))); + _nodes = _nodes.sort((a, b) => { + return a.getNodeInfo().label.localeCompare(b.getNodeInfo().label); + }); + } + nodesResult = _nodes; + } + + return nodesResult; + } catch (error) { + if (error instanceof AzureSubscriptionError) { + void vscode.commands.executeCommand('azure.resource.signin'); + } + return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), this)]; + } + } + + public async getCachedSubscriptions(): Promise { + return this.getCache() ?? []; + } + + public getTreeItem(): vscode.TreeItem | Promise { + const item = new vscode.TreeItem(this._label, vscode.TreeItemCollapsibleState.Collapsed); + item.id = this._id; + item.contextValue = AzureResourceItemType.tenant; + item.iconPath = this.appContext.extensionContext.asAbsolutePath('resources/tenant.svg'); + return item; + } + + protected refreshLabel(): void { + const newLabel = this.generateLabel(); + if (this._label !== newLabel) { + this._label = newLabel; + this.treeChangeHandler.notifyNodeChanged(this); + } + } + + private generateLabel(): string { + let label = this.tenant.displayName; + + if (this._totalSubscriptionCount !== 0) { + label += ` (${this._selectedSubscriptionCount} / ${this._totalSubscriptionCount} subscriptions)`; + } + + return label; + } + + public getNodeInfo(): azdata.NodeInfo { + return { + label: this._label, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + parentNodePath: this.parent?.generateNodePath() ?? '', + nodeStatus: undefined, + nodeType: AzureResourceItemType.tenant, + nodeSubType: undefined, + iconType: AzureResourceItemType.tenant + }; + } + + public get nodePathValue(): string { + return this._id; + } + + private _subscriptionService: IAzureResourceSubscriptionService; + private _subscriptionFilterService: IAzureResourceSubscriptionFilterService; + private _resourceService: AzureResourceService; + + private _id: string; + private _label: string; + public _totalSubscriptionCount = 0; + public _selectedSubscriptionCount = 0; + + private static readonly noSubscriptionsLabel = localize('azure.resource.tree.accountTreeNode.noSubscriptionsLabel', "No Subscriptions found."); +} + +export async function getSubscriptionInfo(account: AzureAccount, tenant: Tenant, subscriptionService: IAzureResourceSubscriptionService, subscriptionFilterService: IAzureResourceSubscriptionFilterService): Promise<{ + subscriptions: azureResource.AzureResourceSubscription[], + total: number, + selected: number +}> { + let subscriptions = await subscriptionService.getSubscriptions(account, [tenant.id]); + const total = subscriptions.length; + let selected = total; + + const selectedSubscriptions = await subscriptionFilterService.getSelectedSubscriptions(account, tenant); + const selectedSubscriptionIds = (selectedSubscriptions || []).map((subscription) => subscription.id); + if (selectedSubscriptionIds.length > 0) { + subscriptions = subscriptions.filter((subscription) => selectedSubscriptionIds.indexOf(subscription.id) !== -1); + selected = selectedSubscriptionIds.length; + } + return { + subscriptions, + total, + selected + }; +} diff --git a/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts b/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts index ecc7579d31..c3ed4e6624 100644 --- a/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts +++ b/extensions/azurecore/src/azureResource/tree/subscriptionTreeNode.ts @@ -18,20 +18,20 @@ import { AzureResourceMessageTreeNode } from '../messageTreeNode'; import { AzureResourceErrorMessageUtil } from '../utils'; import { AzureResourceService } from '../resourceService'; import { AzureResourceResourceTreeNode } from '../resourceTreeNode'; -import { AzureAccount, azureResource } from 'azurecore'; +import { AzureAccount, Tenant, azureResource } from 'azurecore'; export class AzureResourceSubscriptionTreeNode extends AzureResourceContainerTreeNodeBase { public constructor( public readonly account: AzureAccount, public readonly subscription: azureResource.AzureResourceSubscription, - public readonly tenantId: string, + public readonly tenant: Tenant, appContext: AppContext, treeChangeHandler: IAzureResourceTreeChangeHandler, parent: TreeNode ) { super(appContext, treeChangeHandler, parent); - this._id = `account_${this.account.key.accountId}.subscription_${this.subscription.id}.tenant_${this.tenantId}`; + this._id = `account_${this.account.key.accountId}.tenant_${this.tenant.id}.subscription_${this.subscription.id}`; this.setCacheKey(`${this._id}.resources`); } @@ -62,10 +62,7 @@ export class AzureResourceSubscriptionTreeNode extends AzureResourceContainerTre public getTreeItem(): TreeItem | Promise { const item = new TreeItem(this.subscription.name, TreeItemCollapsibleState.Collapsed); item.contextValue = AzureResourceItemType.subscription; - item.iconPath = { - dark: this.appContext.extensionContext.asAbsolutePath('resources/dark/subscription_inverse.svg'), - light: this.appContext.extensionContext.asAbsolutePath('resources/light/subscription.svg') - }; + item.iconPath = this.appContext.extensionContext.asAbsolutePath('resources/subscriptions.svg'); return item; } diff --git a/extensions/azurecore/src/azureResource/tree/tenantTreeNode.ts b/extensions/azurecore/src/azureResource/tree/tenantTreeNode.ts new file mode 100644 index 0000000000..976e3c45b9 --- /dev/null +++ b/extensions/azurecore/src/azureResource/tree/tenantTreeNode.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { AppContext } from '../../appContext'; +import { TreeNode } from '../treeNode'; +import { AzureSubscriptionError } from '../errors'; +import { AzureResourceContainerTreeNodeBase } from './baseTreeNodes'; +import { AzureResourceItemType, AzureResourceServiceNames } from '../constants'; +import { AzureResourceSubscriptionTreeNode } from './subscriptionTreeNode'; +import { AzureResourceMessageTreeNode } from '../messageTreeNode'; +import { AzureResourceErrorMessageUtil } from '../utils'; +import { IAzureResourceTreeChangeHandler } from './treeChangeHandler'; +import { IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService } from '../../azureResource/interfaces'; +import { AzureAccount, Tenant, azureResource } from 'azurecore'; +import { AzureResourceAccountTreeNode } from './accountTreeNode'; + +export class AzureResourceTenantTreeNode extends AzureResourceContainerTreeNodeBase { + public constructor( + public readonly account: AzureAccount, + public readonly tenant: Tenant, + parentNode: AzureResourceAccountTreeNode, + appContext: AppContext, + treeChangeHandler: IAzureResourceTreeChangeHandler + ) { + super(appContext, treeChangeHandler, parentNode); + + this._subscriptionService = this.appContext.getService(AzureResourceServiceNames.subscriptionService); + this._subscriptionFilterService = this.appContext.getService(AzureResourceServiceNames.subscriptionFilterService); + + this._id = `account_${this.account.key.accountId}.tenant_${tenant.id}`; + this.setCacheKey(`${this._id}.subscriptions`); + this._label = this.generateLabel(); + } + + public async getChildren(): Promise { + try { + let subscriptions: azureResource.AzureResourceSubscription[] = []; + + if (this._isClearingCache) { + subscriptions = await this._subscriptionService.getSubscriptions(this.account, [this.tenant.id]); + await this.updateCache(subscriptions); + this._isClearingCache = false; + } else { + subscriptions = await this.getCachedSubscriptions(); + } + + this._totalSubscriptionCount = subscriptions.length; + + const allSubscriptions = await this._subscriptionFilterService.getSelectedSubscriptions(this.account, this.tenant); + const selectedSubscriptions = allSubscriptions?.filter(subscription => subscription.tenant === this.tenant.id); + const selectedSubscriptionIds = (selectedSubscriptions || []).map((subscription) => subscription.id); + if (selectedSubscriptionIds.length > 0) { + subscriptions = subscriptions.filter((subscription) => selectedSubscriptionIds.indexOf(subscription.id) !== -1); + this._selectedSubscriptionCount = selectedSubscriptionIds.length; + } else { + // ALL subscriptions are listed by default + this._selectedSubscriptionCount = this._totalSubscriptionCount; + } + + this.refreshLabel(); + + if (subscriptions.length === 0) { + return [AzureResourceMessageTreeNode.create(AzureResourceTenantTreeNode.noSubscriptionsLabel, this)]; + } else { + let subTreeNodes = await Promise.all(subscriptions.map(async (subscription) => { + return new AzureResourceSubscriptionTreeNode(this.account, subscription, this.tenant, this.appContext, this.treeChangeHandler, this); + })); + return subTreeNodes.sort((a, b) => a.subscription.name.localeCompare(b.subscription.name)); + } + } catch (error) { + if (error instanceof AzureSubscriptionError) { + void vscode.commands.executeCommand('azure.resource.signin'); + } + return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), this)]; + } + } + + public async getCachedSubscriptions(): Promise { + return this.getCache() ?? []; + } + + public getTreeItem(): vscode.TreeItem | Promise { + const item = new vscode.TreeItem(this._label, vscode.TreeItemCollapsibleState.Collapsed); + item.id = this._id; + item.contextValue = AzureResourceItemType.tenant; + item.iconPath = this.appContext.extensionContext.asAbsolutePath('resources/tenant.svg'); + return item; + } + + public getNodeInfo(): azdata.NodeInfo { + return { + label: this._label, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + parentNodePath: this.parent?.generateNodePath() ?? '', + nodeStatus: undefined, + nodeType: AzureResourceItemType.tenant, + nodeSubType: undefined, + iconType: AzureResourceItemType.tenant + }; + } + + public get nodePathValue(): string { + return this._id; + } + + public get totalSubscriptionCount(): number { + return this._totalSubscriptionCount; + } + + public get selectedSubscriptionCount(): number { + return this._selectedSubscriptionCount; + } + + protected refreshLabel(): void { + const newLabel = this.generateLabel(); + if (this._label !== newLabel) { + this._label = newLabel; + this.treeChangeHandler.notifyNodeChanged(this); + } + } + + private generateLabel(): string { + let label = this.tenant.displayName; + + if (this._totalSubscriptionCount !== 0) { + label += ` (${this._selectedSubscriptionCount} / ${this._totalSubscriptionCount} subscriptions)`; + } + + return label; + } + + private _subscriptionService: IAzureResourceSubscriptionService; + private _subscriptionFilterService: IAzureResourceSubscriptionFilterService; + + private _id: string; + private _label: string; + private _totalSubscriptionCount = 0; + private _selectedSubscriptionCount = 0; + + private static readonly noSubscriptionsLabel = localize('azure.resource.tree.accountTreeNode.noSubscriptionsLabel', "No Subscriptions found."); + + sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/extensions/azurecore/src/azureResource/tree/treeProvider.ts b/extensions/azurecore/src/azureResource/tree/treeProvider.ts index a9c51e469d..4ce5281213 100644 --- a/extensions/azurecore/src/azureResource/tree/treeProvider.ts +++ b/extensions/azurecore/src/azureResource/tree/treeProvider.ts @@ -44,7 +44,7 @@ export class AzureResourceTreeProvider implements vscode.TreeDataProvider { if (element) { - return element.getChildren(true); + return element.getChildren(); } if (!this.isSystemInitialized) { @@ -55,11 +55,15 @@ export class AzureResourceTreeProvider implements vscode.TreeDataProvider 0) { - this.accounts = filterAccounts(this.accounts, this.authLibrary); - return this.accounts.map((account) => new AzureResourceAccountTreeNode(account, this.appContext, this)); + if (this.accounts) { + if (this.accounts.length === 0) { + return [new AzureResourceAccountNotSignedInTreeNode()]; + } else { + this.accounts = filterAccounts(this.accounts, this.authLibrary); + return this.accounts.map((account) => new AzureResourceAccountTreeNode(account, this.appContext, this)); + } } else { - return [new AzureResourceAccountNotSignedInTreeNode()]; + return [AzureResourceMessageTreeNode.create(localize('azure.resource.tree.treeProvider.loadingLabel', "Loading ..."), undefined)]; } } catch (error) { return [AzureResourceMessageTreeNode.create(AzureResourceErrorMessageUtil.getErrorMessage(error), undefined)]; @@ -68,7 +72,10 @@ export class AzureResourceTreeProvider implements vscode.TreeDataProvider { try { - this.accounts = filterAccounts(await azdata.accounts.getAllAccounts(), this.authLibrary); + let accounts = await azdata.accounts.getAllAccounts(); + if (accounts) { + this.accounts = filterAccounts(accounts, this.authLibrary); + } // System has been initialized this.setSystemInitialized(); this._onDidChangeTreeData.fire(undefined); diff --git a/extensions/azurecore/src/azureResource/treeNode.ts b/extensions/azurecore/src/azureResource/treeNode.ts index 1ed289a924..7d57ad6405 100644 --- a/extensions/azurecore/src/azureResource/treeNode.ts +++ b/extensions/azurecore/src/azureResource/treeNode.ts @@ -39,7 +39,7 @@ export abstract class TreeNode { } // TODO support filtering by already expanded / not yet expanded - let children = await node.getChildren(false); + let children = await node.getChildren(); if (children) { for (let child of children) { if (filter && filter(child)) { @@ -55,7 +55,7 @@ export abstract class TreeNode { public parent: TreeNode | undefined = undefined; - public abstract getChildren(refreshChildren: boolean): TreeNode[] | Promise; + public abstract getChildren(): TreeNode[] | Promise; public abstract getTreeItem(): vscode.TreeItem | Promise; public abstract getNodeInfo(): azdata.NodeInfo; diff --git a/extensions/azurecore/src/azureResource/utils.ts b/extensions/azurecore/src/azureResource/utils.ts index 6f0663b016..2712650d6b 100644 --- a/extensions/azurecore/src/azureResource/utils.ts +++ b/extensions/azurecore/src/azureResource/utils.ts @@ -14,7 +14,7 @@ import { EOL } from 'os'; import { AppContext } from '../appContext'; import { invalidAzureAccount, invalidTenant, unableToFetchTokenError } from '../localizedConstants'; import { AzureResourceServiceNames } from './constants'; -import { IAzureResourceSubscriptionFilterService, IAzureResourceSubscriptionService } from './interfaces'; +import { IAzureResourceSubscriptionFilterService, IAzureResourceSubscriptionService, IAzureResourceTenantFilterService } from './interfaces'; import { AzureResourceGroupService } from './providers/resourceGroup/resourceGroupService'; import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob'; import providerSettings from '../account-provider/providerSettings'; @@ -418,8 +418,12 @@ export async function getSelectedSubscriptions(appContext: AppContext, account?: } const subscriptionFilterService = appContext.getService(AzureResourceServiceNames.subscriptionFilterService); + const tenantFilterService = appContext.getService(AzureResourceServiceNames.tenantFilterService); try { - result.subscriptions.push(...await subscriptionFilterService.getSelectedSubscriptions(account)); + const tenants = await tenantFilterService.getSelectedTenants(account); + for (const tenant of tenants) { + result.subscriptions.push(...await subscriptionFilterService.getSelectedSubscriptions(account, tenant)); + } } catch (err) { const error = new Error(localize('azure.accounts.getSelectedSubscriptions.queryError', "Error fetching subscriptions for account {0} : {1}", account.displayInfo.displayName, diff --git a/extensions/azurecore/src/extension.ts b/extensions/azurecore/src/extension.ts index 8a5103bee5..40460fff2b 100644 --- a/extensions/azurecore/src/extension.ts +++ b/extensions/azurecore/src/extension.ts @@ -12,7 +12,7 @@ import * as os from 'os'; import { AppContext } from './appContext'; import { AzureAccountProviderService } from './account-provider/azureAccountProviderService'; import { AzureResourceService } from './azureResource/resourceService'; -import { IAzureResourceCacheService, IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService, IAzureTerminalService } from './azureResource/interfaces'; +import { IAzureResourceCacheService, IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService, IAzureTerminalService, IAzureResourceTenantFilterService } from './azureResource/interfaces'; import { AzureResourceServiceNames } from './azureResource/constants'; import { AzureResourceSubscriptionService } from './azureResource/services/subscriptionService'; import { AzureResourceSubscriptionFilterService } from './azureResource/services/subscriptionFilterService'; @@ -29,11 +29,10 @@ import { AzureResourceGroupService } from './azureResource/providers/resourceGro import { Logger } from './utils/Logger'; import { ConnectionDialogTreeProvider } from './azureResource/tree/connectionDialogTreeProvider'; import { AzureDataGridProvider } from './azureDataGridProvider'; -// import { AzureResourceUniversalService } from './azureResource/providers/universal/universalService'; import { AzureResourceUniversalService } from './azureResource/providers/universal/universalService'; import { AzureResourceUniversalTreeDataProvider } from './azureResource/providers/universal/universalTreeDataProvider'; import { AzureResourceUniversalResourceProvider } from './azureResource/providers/universal/universalProvider'; -// import { AzureResourceUniversalTreeDataProvider } from './azureResource/providers/universal/universalTreeDataProvider'; +import { AzureResourceTenantFilterService } from './azureResource/services/tenantFilterService'; let extensionContext: vscode.ExtensionContext; @@ -275,6 +274,7 @@ function registerAzureServices(appContext: AppContext): void { appContext.registerService(AzureResourceServiceNames.cacheService, new AzureResourceCacheService(extensionContext)); appContext.registerService(AzureResourceServiceNames.subscriptionService, new AzureResourceSubscriptionService()); appContext.registerService(AzureResourceServiceNames.subscriptionFilterService, new AzureResourceSubscriptionFilterService(new AzureResourceCacheService(extensionContext))); + appContext.registerService(AzureResourceServiceNames.tenantFilterService, new AzureResourceTenantFilterService(new AzureResourceCacheService(extensionContext))); appContext.registerService(AzureResourceServiceNames.terminalService, new AzureTerminalService(extensionContext)); } diff --git a/extensions/azurecore/src/test/azureResource/providers/database/databaseTreeDataProvider.test.ts b/extensions/azurecore/src/test/azureResource/providers/database/databaseTreeDataProvider.test.ts index a67ce7689e..06980ca57c 100644 --- a/extensions/azurecore/src/test/azureResource/providers/database/databaseTreeDataProvider.test.ts +++ b/extensions/azurecore/src/test/azureResource/providers/database/databaseTreeDataProvider.test.ts @@ -77,6 +77,7 @@ const mockDatabases: azureResource.AzureResourceDatabase[] = [ name: 'mock database 1', id: 'mock-id-1', provider: DATABASE_PROVIDER_ID, + tenant: 'mockTenantId', serverName: 'mock database server 1', serverFullName: 'mock database server full name 1', loginName: 'mock login', @@ -90,6 +91,7 @@ const mockDatabases: azureResource.AzureResourceDatabase[] = [ name: 'mock database 2', id: 'mock-id-2', provider: DATABASE_PROVIDER_ID, + tenant: 'mockTenantId', serverName: 'mock database server 2', serverFullName: 'mock database server full name 2', loginName: 'mock login', @@ -140,7 +142,7 @@ describe('AzureResourceDatabaseTreeDataProvider.getChildren', function (): void const child = children[0]; should(child.id).equal('azure.resource.providers.database.treeDataProvider.databaseContainer'); - should(child.label).equal('SQL database'); + should(child.label).equal('SQL databases'); should(child.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); should(child.contextValue).equal('azure.resource.itemType.databaseContainer'); }); @@ -160,7 +162,7 @@ describe('AzureResourceDatabaseTreeDataProvider.getChildren', function (): void should(child.account).equal(mockAccount); should(child.subscription).equal(mockSubscription); should(child.tenantId).equal(mockTenantId); - should(child.treeItem.id).equal(`databaseServer_${mockAccount.key.accountId}${database.serverFullName}.database_${database.id}`); + should(child.treeItem.id).equal(`database_${mockAccount.key.accountId}${database.tenant}${database.serverFullName}.database_${database.id}`); should(child.treeItem.label).equal(`${database.name} (${database.serverName})`); should(child.treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); should(child.treeItem.contextValue).equal(AzureResourceItemType.database); 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 b87abb565d..f99106a1c4 100644 --- a/extensions/azurecore/src/test/azureResource/providers/databaseServer/databaseServerTreeDataProvider.test.ts +++ b/extensions/azurecore/src/test/azureResource/providers/databaseServer/databaseServerTreeDataProvider.test.ts @@ -60,7 +60,7 @@ const mockResourceRootNode: azureResource.IAzureResourceNode = { treeItem: { id: 'mock_resource_root_node', label: 'mock resource root node', - iconPath: undefined, + iconPath: '', collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, contextValue: 'mock_resource_root_node' } @@ -76,6 +76,7 @@ const mockDatabaseServers: azureResource.AzureResourceDatabaseServer[] = [ name: 'mock database server 1', id: 'mock-id-1', provider: DATABASE_SERVER_PROVIDER_ID, + tenant: 'mockTenantId', fullName: 'mock database server full name 1', loginName: 'mock login', defaultDatabaseName: 'master', @@ -89,6 +90,7 @@ const mockDatabaseServers: azureResource.AzureResourceDatabaseServer[] = [ name: 'mock database server 2', id: 'mock-id-2', provider: DATABASE_SERVER_PROVIDER_ID, + tenant: 'mockTenantId', fullName: 'mock database server full name 2', loginName: 'mock login', defaultDatabaseName: 'master', @@ -139,7 +141,7 @@ describe('AzureResourceDatabaseServerTreeDataProvider.getChildren', function (): const child = children[0]; should(child.id).equal('azure.resource.providers.databaseServer.treeDataProvider.databaseServerContainer'); - should(child.label).equal('SQL server'); + should(child.label).equal('SQL servers'); should(child.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); should(child.contextValue).equal('azure.resource.itemType.databaseServerContainer'); }); @@ -159,7 +161,7 @@ describe('AzureResourceDatabaseServerTreeDataProvider.getChildren', function (): should(child.account).equal(mockAccount); should(child.subscription).equal(mockSubscription); should(child.tenantId).equal(mockTenantId); - should(child.treeItem.id).equal(`databaseServer_${mockAccount.key.accountId}${databaseServer.id}`); + should(child.treeItem.id).equal(`databaseServer_${mockAccount.key.accountId}${databaseServer.tenant}${databaseServer.id}`); should(child.treeItem.label).equal(databaseServer.name); should(child.treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); should(child.treeItem.contextValue).equal(AzureResourceItemType.databaseServer); diff --git a/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts index 1b49556584..ccf1c17fb1 100644 --- a/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts +++ b/extensions/azurecore/src/test/azureResource/tree/accountTreeNode.test.ts @@ -15,6 +15,7 @@ import { IAzureResourceCacheService, IAzureResourceSubscriptionService, IAzureResourceSubscriptionFilterService, + IAzureResourceTenantFilterService, } from '../../../azureResource/interfaces'; import { IAzureResourceTreeChangeHandler } from '../../../azureResource/tree/treeChangeHandler'; import { AzureResourceAccountTreeNode } from '../../../azureResource/tree/accountTreeNode'; @@ -24,15 +25,15 @@ import { AzureResourceMessageTreeNode } from '../../../azureResource/messageTree import { generateGuid } from '../../../azureResource/utils'; import { AzureAccount, azureResource } from 'azurecore'; import allSettings from '../../../account-provider/providerSettings'; +import { AzureResourceTenantTreeNode } from '../../../azureResource/tree/tenantTreeNode'; // Mock services let mockExtensionContext: TypeMoq.IMock; let mockCacheService: TypeMoq.IMock; -let mockSubscriptionServiceADAL: TypeMoq.IMock; -let mockSubscriptionServiceMSAL: TypeMoq.IMock; +let mockSubscriptionService: TypeMoq.IMock; let mockSubscriptionFilterService: TypeMoq.IMock; -let mockAppContextADAL: AppContext; -let mockAppContextMSAL: AppContext; +let mockTenantFilterService: TypeMoq.IMock; +let mockAppContext: AppContext; let mockTreeChangeHandler: TypeMoq.IMock; // Mock test data @@ -41,6 +42,13 @@ const mockTenant = { id: mockTenantId, displayName: 'Mock Tenant' }; +const mockTenantAlternative = { + id: 'mock_tenant_id_alt', + displayName: 'Mock Tenant Alternative' +}; + +const mockTenants = [mockTenant, mockTenantAlternative]; + const mockAccount: AzureAccount = { key: { accountId: '97915f6d-84fa-4926-b60c-38db64327ad7', @@ -54,9 +62,7 @@ const mockAccount: AzureAccount = { email: '97915f6d-84fa-4926-b60c-38db64327ad7' }, properties: { - tenants: [ - mockTenant - ], + tenants: mockTenants, owningTenant: mockTenant, providerSettings: { settings: allSettings[0].metadata.settings, @@ -68,6 +74,8 @@ const mockAccount: AzureAccount = { isStale: false }; +const mockFilteredTenants = [mockTenant]; + const mock_subscription_id_1 = 'mock_subscription_1'; const mockSubscription1: azureResource.AzureResourceSubscription = { id: mock_subscription_id_1, @@ -97,25 +105,20 @@ describe('AzureResourceAccountTreeNode.info', function (): void { beforeEach(() => { mockExtensionContext = TypeMoq.Mock.ofType(); mockCacheService = TypeMoq.Mock.ofType(); - mockSubscriptionServiceADAL = TypeMoq.Mock.ofType(); - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionServiceMSAL = TypeMoq.Mock.ofType(); - mockSubscriptionServiceMSAL.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionService = TypeMoq.Mock.ofType(); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); mockSubscriptionFilterService = TypeMoq.Mock.ofType(); + mockTenantFilterService = TypeMoq.Mock.ofType(); mockTreeChangeHandler = TypeMoq.Mock.ofType(); mockSubscriptionCache = []; - mockAppContextADAL = new AppContext(mockExtensionContext.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionServiceADAL.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); - - mockAppContextMSAL = new AppContext(mockExtensionContext.object); - mockAppContextMSAL.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); - mockAppContextMSAL.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionServiceMSAL.object); - mockAppContextMSAL.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); + mockAppContext = new AppContext(mockExtensionContext.object); + mockAppContext.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); + mockAppContext.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionService.object); + mockAppContext.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); + mockAppContext.registerService(AzureResourceServiceNames.tenantFilterService, mockTenantFilterService.object); mockCacheService.setup((o) => o.generateKey(TypeMoq.It.isAnyString())).returns(() => generateGuid()); mockCacheService.setup((o) => o.get(TypeMoq.It.isAnyString())).returns(() => mockSubscriptionCache); @@ -129,8 +132,8 @@ describe('AzureResourceAccountTreeNode.info', function (): void { sinon.restore(); }); - it('Should be correct when created for ADAL.', async function (): Promise { - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + it('Should be correct when created.', async function (): Promise { + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); const accountTreeNodeId = `account_${mockAccount.key.accountId}`; @@ -139,7 +142,7 @@ describe('AzureResourceAccountTreeNode.info', function (): void { const treeItem = await accountTreeNode.getTreeItem(); should(treeItem.id).equal(accountTreeNodeId); should(treeItem.label).equal(mockAccount.displayInfo.displayName); - should(treeItem.contextValue).equal(AzureResourceItemType.account); + should(treeItem.contextValue).equal(AzureResourceItemType.multipleTenantAccount); should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); const nodeInfo = accountTreeNode.getNodeInfo(); @@ -149,146 +152,126 @@ describe('AzureResourceAccountTreeNode.info', function (): void { should(nodeInfo.iconType).equal(AzureResourceItemType.account); }); - it('Should be correct when created for MSAL.', async function (): Promise { - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextMSAL, mockTreeChangeHandler.object); - - const accountTreeNodeId = `account_${mockAccount.key.accountId}`; - - should(accountTreeNode.nodePathValue).equal(accountTreeNodeId); - - const treeItem = await accountTreeNode.getTreeItem(); - should(treeItem.id).equal(accountTreeNodeId); - should(treeItem.label).equal(mockAccount.displayInfo.displayName); - should(treeItem.contextValue).equal(AzureResourceItemType.account); - should(treeItem.collapsibleState).equal(vscode.TreeItemCollapsibleState.Collapsed); - - const nodeInfo = accountTreeNode.getNodeInfo(); - should(nodeInfo.label).equal(mockAccount.displayInfo.displayName); - should(nodeInfo.isLeaf).false(); - should(nodeInfo.nodeType).equal(AzureResourceItemType.account); - should(nodeInfo.iconType).equal(AzureResourceItemType.account); - }); - - it('Should be correct when there are subscriptions listed for ADAL.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve([])); + it('Should be correct when there are tenants available.', async function (): Promise { + mockTenantFilterService.setup((o) => o.getSelectedTenants(mockAccount)).returns(() => Promise.resolve(mockFilteredTenants)); sinon.stub(azdata.accounts, 'getAccountSecurityToken').resolves(mockToken); - const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredTenants.length} / ${mockTenants.length} tenants)`; - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); - const subscriptionNodes = await accountTreeNode.getChildren(); + const tenantNodes = await accountTreeNode.getChildren(); + + should(tenantNodes).Array(); + should(tenantNodes.length).equal(mockFilteredTenants.length); + + const treeItem = await accountTreeNode.getTreeItem(); + should(treeItem.label).equal(accountTreeNodeLabel); + + const nodeInfo = accountTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(accountTreeNodeLabel); + }); + + it('Should be correct when there are subscriptions listed.', async function (): Promise { + mockTenantFilterService.setup((o) => o.getSelectedTenants(mockAccount)).returns(() => Promise.resolve(mockFilteredTenants)); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount, mockTenant)).returns(() => Promise.resolve([])); + sinon.stub(azdata.accounts, 'getAccountSecurityToken').resolves(mockToken); + + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredTenants.length} / ${mockTenants.length} tenants)`; + const tenantTreeNodeLabel = `${mockTenant.displayName} (${mockSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + + // Validate account tree node + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); + const tenantNodes = await accountTreeNode.getChildren(); + + should(tenantNodes).Array(); + should(tenantNodes.length).equal(mockFilteredTenants.length); + + const treeItem = await accountTreeNode.getTreeItem(); + should(treeItem.label).equal(accountTreeNodeLabel); + + const nodeInfo = accountTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(accountTreeNodeLabel); + + // Validate tenant tree node + const tenantTreeNode = tenantNodes[0]; + const subscriptions = await tenantTreeNode.getChildren(); + + should(subscriptions).Array(); + should(subscriptions.length).equal(mockSubscriptions.length); + + const subTreeItem = await tenantTreeNode.getTreeItem(); + should(subTreeItem.label).equal(tenantTreeNodeLabel); + + const subNodeInfo = tenantTreeNode.getNodeInfo(); + should(subNodeInfo.label).equal(tenantTreeNodeLabel); + }); + + it('Should only show subscriptions with valid tokens.', async function (): Promise { + mockTenantFilterService.setup((o) => o.getSelectedTenants(mockAccount)).returns(() => Promise.resolve(mockTenants)); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount, mockTenant)).returns(() => Promise.resolve(mockSubscriptions)); + sinon.stub(azdata.accounts, 'getAccountSecurityToken').onFirstCall().resolves(mockToken); + + const tenantTreeNodeLabel = `${mockTenant.displayName} (${mockSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockTenants.length} / ${mockTenants.length} tenants)`; + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); + const tenantTreeNode = (await accountTreeNode.getChildren())[0]; + const subscriptionNodes = await tenantTreeNode.getChildren(); + + // Validate account tree node + const treeItem = await accountTreeNode.getTreeItem(); + should(treeItem.label).equal(accountTreeNodeLabel); + + const nodeInfo = accountTreeNode.getNodeInfo(); + should(nodeInfo.label).equal(accountTreeNodeLabel); + + // Validate tenant tree node + const tenantTreeItem = await tenantTreeNode.getTreeItem(); + should(tenantTreeItem.label).equal(tenantTreeNodeLabel); + + const tenantNodeInfo = tenantTreeNode.getNodeInfo(); + should(tenantNodeInfo.label).equal(tenantTreeNodeLabel); should(subscriptionNodes).Array(); should(subscriptionNodes.length).equal(mockSubscriptions.length); - const treeItem = await accountTreeNode.getTreeItem(); - should(treeItem.label).equal(accountTreeNodeLabel); - - const nodeInfo = accountTreeNode.getNodeInfo(); - should(nodeInfo.label).equal(accountTreeNodeLabel); }); - it('Should be correct when there are subscriptions listed for MSAL.', async function (): Promise { - mockSubscriptionServiceMSAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve([])); + it('Should be correct when there are subscriptions filtered.', async function (): Promise { + mockTenantFilterService.setup((o) => o.getSelectedTenants(mockAccount)).returns(() => Promise.resolve(mockFilteredTenants)); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount, mockTenant)).returns(() => Promise.resolve(mockFilteredSubscriptions)); sinon.stub(azdata.accounts, 'getAccountSecurityToken').resolves(mockToken); - const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + const tenantTreeNodeLabel = `${mockTenant.displayName} (${mockFilteredSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; + const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredTenants.length} / ${mockTenants.length} tenants)`; - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextMSAL, mockTreeChangeHandler.object); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); + const tenantNodes = await accountTreeNode.getChildren(); - const subscriptionNodes = await accountTreeNode.getChildren(); + should(tenantNodes).Array(); + should(tenantNodes.length).equal(mockFilteredTenants.length); - should(subscriptionNodes).Array(); - should(subscriptionNodes.length).equal(mockSubscriptions.length); + const tenantTreeNode = tenantNodes[0]; + const subscriptionNodes = await tenantTreeNode.getChildren(); const treeItem = await accountTreeNode.getTreeItem(); should(treeItem.label).equal(accountTreeNodeLabel); const nodeInfo = accountTreeNode.getNodeInfo(); should(nodeInfo.label).equal(accountTreeNodeLabel); - }); - - it('Should only show subscriptions with valid tokens for ADAL.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); - sinon.stub(azdata.accounts, 'getAccountSecurityToken').onFirstCall().resolves(mockToken); - const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; - - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); - - const subscriptionNodes = await accountTreeNode.getChildren(); - - should(subscriptionNodes).Array(); - should(subscriptionNodes.length).equal(1); - - const treeItem = await accountTreeNode.getTreeItem(); - should(treeItem.label).equal(accountTreeNodeLabel); - - const nodeInfo = accountTreeNode.getNodeInfo(); - should(nodeInfo.label).equal(accountTreeNodeLabel); - }); - - it('Should only show subscriptions with valid tokens for MSAL.', async function (): Promise { - mockSubscriptionServiceMSAL.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); - sinon.stub(azdata.accounts, 'getAccountSecurityToken').onFirstCall().resolves(mockToken); - const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; - - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextMSAL, mockTreeChangeHandler.object); - - const subscriptionNodes = await accountTreeNode.getChildren(); - - should(subscriptionNodes).Array(); - should(subscriptionNodes.length).equal(1); - - const treeItem = await accountTreeNode.getTreeItem(); - should(treeItem.label).equal(accountTreeNodeLabel); - - const nodeInfo = accountTreeNode.getNodeInfo(); - should(nodeInfo.label).equal(accountTreeNodeLabel); - }); - - it('Should be correct when there are subscriptions filtered for ADAL.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); - sinon.stub(azdata.accounts, 'getAccountSecurityToken').resolves(mockToken); - const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; - - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); - - const subscriptionNodes = await accountTreeNode.getChildren(); should(subscriptionNodes).Array(); should(subscriptionNodes.length).equal(mockFilteredSubscriptions.length); - const treeItem = await accountTreeNode.getTreeItem(); - should(treeItem.label).equal(accountTreeNodeLabel); + const tenantTreeItem = await tenantTreeNode.getTreeItem(); + should(tenantTreeItem.label).equal(tenantTreeNodeLabel); - const nodeInfo = accountTreeNode.getNodeInfo(); - should(nodeInfo.label).equal(accountTreeNodeLabel); - }); - - it('Should be correct when there are subscriptions filtered for MSAL.', async function (): Promise { - mockSubscriptionServiceMSAL.setup((o) => o.getSubscriptions(mockAccount)).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); - sinon.stub(azdata.accounts, 'getAccountSecurityToken').resolves(mockToken); - const accountTreeNodeLabel = `${mockAccount.displayInfo.displayName} (${mockFilteredSubscriptions.length} / ${mockSubscriptions.length} subscriptions)`; - - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextMSAL, mockTreeChangeHandler.object); - - const subscriptionNodes = await accountTreeNode.getChildren(); - - should(subscriptionNodes).Array(); - should(subscriptionNodes.length).equal(mockFilteredSubscriptions.length); - - const treeItem = await accountTreeNode.getTreeItem(); - should(treeItem.label).equal(accountTreeNodeLabel); - - const nodeInfo = accountTreeNode.getNodeInfo(); - should(nodeInfo.label).equal(accountTreeNodeLabel); + const tenantNodeInfo = tenantTreeNode.getNodeInfo(); + should(tenantNodeInfo.label).equal(tenantTreeNodeLabel); }); }); @@ -296,23 +279,18 @@ describe('AzureResourceAccountTreeNode.getChildren', function (): void { beforeEach(() => { mockExtensionContext = TypeMoq.Mock.ofType(); mockCacheService = TypeMoq.Mock.ofType(); - mockSubscriptionServiceADAL = TypeMoq.Mock.ofType(); - mockSubscriptionServiceMSAL = TypeMoq.Mock.ofType(); + mockSubscriptionService = TypeMoq.Mock.ofType(); mockSubscriptionFilterService = TypeMoq.Mock.ofType(); mockTreeChangeHandler = TypeMoq.Mock.ofType(); mockSubscriptionCache = []; - mockAppContextADAL = new AppContext(mockExtensionContext.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionServiceADAL.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); - - mockAppContextMSAL = new AppContext(mockExtensionContext.object); - mockAppContextMSAL.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); - mockAppContextMSAL.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionServiceMSAL.object); - mockAppContextMSAL.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); + mockAppContext = new AppContext(mockExtensionContext.object); + mockAppContext.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); + mockAppContext.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionService.object); + mockAppContext.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); + mockAppContext.registerService(AzureResourceServiceNames.tenantFilterService, mockTenantFilterService.object); sinon.stub(azdata.accounts, 'getAccountSecurityToken').resolves(mockToken); mockCacheService.setup((o) => o.generateKey(TypeMoq.It.isAnyString())).returns(() => generateGuid()); @@ -327,28 +305,27 @@ describe('AzureResourceAccountTreeNode.getChildren', function (): void { sinon.restore(); }); - it('Should load subscriptions from scratch and update cache when it is clearing cache for ADAL.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve([])); + it('Should load subscriptions from scratch and update cache when it is clearing cache.', async function (): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount, mockTenant)).returns(() => Promise.resolve([])); - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); + const tenantTreeNode = new AzureResourceTenantTreeNode(mockAccount, mockTenant, accountTreeNode, mockAppContext, mockTreeChangeHandler.object); + const children = await tenantTreeNode.getChildren(); - const children = await accountTreeNode.getChildren(); - - mockSubscriptionServiceADAL.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.exactly(0)); mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); - mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount), TypeMoq.Times.once()); + mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); - mockTreeChangeHandler.verify((o) => o.notifyNodeChanged(accountTreeNode), TypeMoq.Times.once()); + mockTreeChangeHandler.verify((o) => o.notifyNodeChanged(tenantTreeNode), TypeMoq.Times.once()); - should(accountTreeNode.totalSubscriptionCount).equal(mockSubscriptions.length); - should(accountTreeNode.selectedSubscriptionCount).equal(mockSubscriptions.length); - should(accountTreeNode.isClearingCache).false(); + should(tenantTreeNode.totalSubscriptionCount).equal(mockSubscriptions.length); + should(tenantTreeNode.selectedSubscriptionCount).equal(mockSubscriptions.length); + should(tenantTreeNode.isClearingCache).false(); should(children).Array(); should(children.length).equal(mockSubscriptions.length); - should(mockSubscriptionCache).deepEqual(mockSubscriptions); for (let ix = 0; ix < mockSubscriptions.length; ix++) { @@ -356,39 +333,38 @@ describe('AzureResourceAccountTreeNode.getChildren', function (): void { const subscription = mockSubscriptions[ix]; should(child).instanceof(AzureResourceSubscriptionTreeNode); - should(child.nodePathValue).equal(`account_${mockAccount.key.accountId}.subscription_${subscription.id}.tenant_${mockTenantId}`); + should(child.nodePathValue).equal(`account_${mockAccount.key.accountId}.tenant_${mockTenantId}.subscription_${subscription.id}`); } }); it('Should load subscriptions from cache when it is not clearing cache.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve([])); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount, mockTenant)).returns(() => Promise.resolve([])); - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); - await accountTreeNode.getChildren(); - const children = await accountTreeNode.getChildren(); + const tenants = await accountTreeNode.getChildren(); + await tenants[0].getChildren(); + const children = await tenants[0].getChildren(); - - mockSubscriptionServiceADAL.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.once()); mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); should(children.length).equal(mockSubscriptionCache.length); for (let ix = 0; ix < mockSubscriptionCache.length; ix++) { - should(children[ix].nodePathValue).equal(`account_${mockAccount.key.accountId}.subscription_${mockSubscriptionCache[ix].id}.tenant_${mockTenantId}`); + should(children[ix].nodePathValue).equal(`account_${mockAccount.key.accountId}.tenant_${mockTenantId}.subscription_${mockSubscriptionCache[ix].id}`); } }); it('Should handle when there is no subscriptions.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve([])); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve([])); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); + const tenantTreeNode = new AzureResourceTenantTreeNode(mockAccount, mockTenant, accountTreeNode, mockAppContext, mockTreeChangeHandler.object); + const children = await tenantTreeNode.getChildren(); - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); - - const children = await accountTreeNode.getChildren(); - - should(accountTreeNode.totalSubscriptionCount).equal(0); + should(tenantTreeNode.totalSubscriptionCount).equal(0); should(children).Array(); should(children.length).equal(1); @@ -397,36 +373,51 @@ describe('AzureResourceAccountTreeNode.getChildren', function (): void { should(children[0].getNodeInfo().label).equal('No Subscriptions found.'); }); - it('Should honor subscription filtering.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => Promise.resolve(mockFilteredSubscriptions)); - - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + it('Should honor tenant filtering.', async function (): Promise { + mockTenantFilterService.setup((o) => o.getSelectedTenants(mockAccount)).returns(() => Promise.resolve(mockFilteredTenants)); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); const children = await accountTreeNode.getChildren(); - mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount), TypeMoq.Times.once()); + should(accountTreeNode.selectedTenantCount).equal(mockFilteredTenants.length); + should(children.length).equal(mockFilteredTenants.length); - should(accountTreeNode.selectedSubscriptionCount).equal(mockFilteredSubscriptions.length); - should(children.length).equal(mockFilteredSubscriptions.length); + for (let ix = 0; ix < mockFilteredTenants.length; ix++) { + should(children[ix].nodePathValue).equal(`account_${mockAccount.key.accountId}.tenant_${mockTenantId}`); + } + }); + + it('Should honor subscription filtering.', async function (): Promise { + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockFilteredSubscriptions)); + + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); + const tenantTreeNode = new AzureResourceTenantTreeNode(mockAccount, mockTenant, accountTreeNode, mockAppContext, mockTreeChangeHandler.object); + const subscriptions = await tenantTreeNode.getChildren(); + + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); + + should(subscriptions.length).equal(mockFilteredSubscriptions.length); + should(tenantTreeNode.selectedSubscriptionCount).equal(mockFilteredSubscriptions.length); for (let ix = 0; ix < mockFilteredSubscriptions.length; ix++) { - should(children[ix].nodePathValue).equal(`account_${mockAccount.key.accountId}.subscription_${mockFilteredSubscriptions[ix].id}.tenant_${mockTenantId}`); + const subscription = mockSubscriptions[ix]; + should(subscriptions[ix].nodePathValue).equal(`account_${mockAccount.key.accountId}.tenant_${mockTenantId}.subscription_${subscription.id}`); } }); it('Should handle errors.', async function (): Promise { - mockSubscriptionServiceADAL.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); + mockSubscriptionService.setup((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny())).returns(() => Promise.resolve(mockSubscriptions)); const mockError = 'Test error'; - mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount)).returns(() => { throw new Error(mockError); }); + mockSubscriptionFilterService.setup((o) => o.getSelectedSubscriptions(mockAccount, mockTenant)).returns(() => { throw new Error(mockError); }); - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); - const children = await accountTreeNode.getChildren(); + const tenants = await accountTreeNode.getChildren(); + const children = await tenants[0].getChildren(); - mockSubscriptionServiceADAL.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); - mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount), TypeMoq.Times.once()); + mockSubscriptionService.verify((o) => o.getSubscriptions(mockAccount, TypeMoq.It.isAny()), TypeMoq.Times.once()); + mockSubscriptionFilterService.verify((o) => o.getSelectedSubscriptions(mockAccount, mockTenant), TypeMoq.Times.once()); mockCacheService.verify((o) => o.get(TypeMoq.It.isAnyString()), TypeMoq.Times.never()); mockCacheService.verify((o) => o.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); @@ -442,17 +433,17 @@ describe('AzureResourceAccountTreeNode.clearCache', function (): void { beforeEach(() => { mockExtensionContext = TypeMoq.Mock.ofType(); mockCacheService = TypeMoq.Mock.ofType(); - mockSubscriptionServiceADAL = TypeMoq.Mock.ofType(); + mockSubscriptionService = TypeMoq.Mock.ofType(); mockSubscriptionFilterService = TypeMoq.Mock.ofType(); mockTreeChangeHandler = TypeMoq.Mock.ofType(); mockSubscriptionCache = []; - mockAppContextADAL = new AppContext(mockExtensionContext.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionServiceADAL.object); - mockAppContextADAL.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); + mockAppContext = new AppContext(mockExtensionContext.object); + mockAppContext.registerService(AzureResourceServiceNames.cacheService, mockCacheService.object); + mockAppContext.registerService(AzureResourceServiceNames.subscriptionService, mockSubscriptionService.object); + mockAppContext.registerService(AzureResourceServiceNames.subscriptionFilterService, mockSubscriptionFilterService.object); sinon.stub(azdata.accounts, 'getAccountSecurityToken').returns(Promise.resolve(mockToken)); mockCacheService.setup((o) => o.generateKey(TypeMoq.It.isAnyString())).returns(() => generateGuid()); @@ -468,7 +459,7 @@ describe('AzureResourceAccountTreeNode.clearCache', function (): void { }); it('Should clear cache.', async function (): Promise { - const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContextADAL, mockTreeChangeHandler.object); + const accountTreeNode = new AzureResourceAccountTreeNode(mockAccount, mockAppContext, mockTreeChangeHandler.object); accountTreeNode.clearCache(); should(accountTreeNode.isClearingCache).true(); }); diff --git a/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts b/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts index cec3182f28..b5abd6aff2 100644 --- a/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts +++ b/extensions/azurecore/src/test/azureResource/tree/subscriptionTreeNode.test.ts @@ -17,7 +17,7 @@ import { AzureResourceService } from '../../../azureResource/resourceService'; import { AzureResourceResourceTreeNode } from '../../../azureResource/resourceTreeNode'; import { IAzureResourceCacheService } from '../../../azureResource/interfaces'; import { generateGuid } from '../../../azureResource/utils'; -import { AzureAccount, AzureAccountProperties, azureResource } from 'azurecore'; +import { AzureAccount, AzureAccountProperties, Tenant, azureResource } from 'azurecore'; import { TreeNode } from '../../../azureResource/treeNode'; // Mock services @@ -47,6 +47,13 @@ const mockAccount: AzureAccount = { const mockTenantId: string = 'mock_tenant'; const mockSubscriptionId: string = 'mock_subscription'; +const mockTenant: Tenant = { + id: mockTenantId, + displayName: 'mock_tenant', + userId: 'test@email.com', + tenantCategory: 'Home' +} + const mockSubscription: azureResource.AzureResourceSubscription = { id: mockSubscriptionId, name: 'mock subscription', @@ -96,9 +103,9 @@ describe('AzureResourceSubscriptionTreeNode.info', function (): void { }); it('Should be correct when created.', async function (): Promise { - const subscriptionTreeNode = new AzureResourceSubscriptionTreeNode(mockAccount, mockSubscription, mockTenantId, appContext, mockTreeChangeHandler.object, TypeMoq.Mock.ofType().object); + const subscriptionTreeNode = new AzureResourceSubscriptionTreeNode(mockAccount, mockSubscription, mockTenant, appContext, mockTreeChangeHandler.object, TypeMoq.Mock.ofType().object); - should(subscriptionTreeNode.nodePathValue).equal(`account_${mockAccount.key.accountId}.subscription_${mockSubscription.id}.tenant_${mockTenantId}`); + should(subscriptionTreeNode.nodePathValue).equal(`account_${mockAccount.key.accountId}.tenant_${mockTenantId}.subscription_${mockSubscription.id}`); const treeItem = await subscriptionTreeNode.getTreeItem(); should(treeItem.label).equal(mockSubscription.name); @@ -147,7 +154,7 @@ describe('AzureResourceSubscriptionTreeNode.getChildren', function (): void { }); it('Should return resource containers.', async function (): Promise { - const subscriptionTreeNode = new AzureResourceSubscriptionTreeNode(mockAccount, mockSubscription, mockTenantId, appContext, mockTreeChangeHandler.object, TypeMoq.Mock.ofType().object); + const subscriptionTreeNode = new AzureResourceSubscriptionTreeNode(mockAccount, mockSubscription, mockTenant, appContext, mockTreeChangeHandler.object, TypeMoq.Mock.ofType().object); const children = await subscriptionTreeNode.getChildren(); mockResourceTreeDataProvider1.verify((o) => o.getRootChildren(), TypeMoq.Times.once()); diff --git a/src/sql/workbench/services/accountManagement/browser/media/accountListRenderer.css b/src/sql/workbench/services/accountManagement/browser/media/accountListRenderer.css index f72a84eca9..18442335f9 100644 --- a/src/sql/workbench/services/accountManagement/browser/media/accountListRenderer.css +++ b/src/sql/workbench/services/accountManagement/browser/media/accountListRenderer.css @@ -12,7 +12,8 @@ .list-row.account-picker-list .label, .list-row.tenant-picker-list .label { flex: 1 1 auto; - margin-left: 12px; + margin-left: 8px; + margin-top: 6px; overflow: hidden; }