diff --git a/extensions/arc/images/collapse-down-inverse.svg b/extensions/arc/images/collapse-down-inverse.svg deleted file mode 100644 index 81848c6598..0000000000 --- a/extensions/arc/images/collapse-down-inverse.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/extensions/arc/images/collapse-down.svg b/extensions/arc/images/collapse-down.svg deleted file mode 100644 index 122ab8abfb..0000000000 --- a/extensions/arc/images/collapse-down.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/extensions/arc/images/collapse-up-inverse.svg b/extensions/arc/images/collapse-up-inverse.svg deleted file mode 100644 index a207d0f145..0000000000 --- a/extensions/arc/images/collapse-up-inverse.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/extensions/arc/images/collapse-up.svg b/extensions/arc/images/collapse-up.svg deleted file mode 100644 index 4c7c97e181..0000000000 --- a/extensions/arc/images/collapse-up.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/extensions/arc/images/data_controller.svg b/extensions/arc/images/data_controller.svg new file mode 100644 index 0000000000..5e2274e46b --- /dev/null +++ b/extensions/arc/images/data_controller.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + Data_controller + + + + + + + + + + diff --git a/extensions/arc/package.json b/extensions/arc/package.json index 89c6b55d27..f49bf2846b 100644 --- a/extensions/arc/package.json +++ b/extensions/arc/package.json @@ -14,7 +14,8 @@ "activationEvents": [ "onCommand:arc.manageArcController", "onCommand:arc.manageMiaa", - "onCommand:arc.managePostgres" + "onCommand:arc.managePostgres", + "onView:azureArc" ], "repository": { "type": "git", @@ -22,6 +23,14 @@ }, "main": "./out/extension", "contributes": { + "dataExplorer": { + "azureArc": [ + { + "id": "azureArc", + "name": "%arc.view.title%" + } + ] + }, "commands": [ { "command": "arc.manageArcController", @@ -34,6 +43,19 @@ { "command": "arc.managePostgres", "title": "%arc.managePostgres%" + }, + { + "command": "arc.openDashboard", + "title": "%arc.openDashboard%" + }, + { + "command": "arc.addController", + "title": "%command.addController.title%", + "icon": "$(add)" + }, + { + "command": "arc.removeController", + "title": "%command.removeController.title%" } ], "menus": { @@ -49,6 +71,33 @@ { "command": "arc.managePostgres", "when": "false" + }, + { + "command": "arc.openDashboard", + "when": "false" + }, + { + "command": "arc.removeController", + "when": "false" + } + ], + "view/title": [ + { + "command": "arc.addController", + "when": "view == azureArc", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "arc.openDashboard", + "when": "view == azureArc && viewItem != loading", + "group": "navigation@1" + }, + { + "command": "arc.removeController", + "when": "view == azureArc && viewItem == dataControllers", + "group": "navigation@2" } ] }, diff --git a/extensions/arc/package.nls.json b/extensions/arc/package.nls.json index 317a1f4305..3158a8c03a 100644 --- a/extensions/arc/package.nls.json +++ b/extensions/arc/package.nls.json @@ -5,5 +5,9 @@ "arc.ignoreSslVerification.desc" : "Ignore SSL verification errors against the controller endpoint if true", "arc.manageMiaa": "Manage MIAA", "arc.managePostgres": "Manage Postgres", - "arc.manageArcController": "Manage Arc Controller" + "arc.manageArcController": "Manage Arc Controller", + "arc.view.title" : "Azure Arc Controllers", + "command.addController.title": "Connect to Controller", + "command.removeController.title": "Remove Controller", + "arc.openDashboard": "Manage" } diff --git a/extensions/arc/src/common/utils.ts b/extensions/arc/src/common/utils.ts index 4d52d5b219..19954753a5 100644 --- a/extensions/arc/src/common/utils.ts +++ b/extensions/arc/src/common/utils.ts @@ -46,16 +46,26 @@ export async function getAzurecoreApi(): Promise { return azurecoreApi; } -export function getResourceTypeIcon(resourceType: string): IconPath | undefined { +/** + * Gets the IconPath for the specified resource type, or undefined if the type is unknown. + * @param resourceType The resource type + */ +export function getResourceTypeIcon(resourceType: string | undefined): IconPath | undefined { switch (resourceType) { case ResourceType.sqlManagedInstances: return IconPathHelper.miaa; case ResourceType.postgresInstances: return IconPathHelper.postgres; + case ResourceType.dataControllers: + return IconPathHelper.controller; } return undefined; } +/** + * Returns the text to display for known connection modes + * @param connectionMode The string repsenting the connection mode + */ export function getConnectionModeDisplayText(connectionMode: string | undefined): string { connectionMode = connectionMode ?? ''; switch (connectionMode) { @@ -67,6 +77,30 @@ export function getConnectionModeDisplayText(connectionMode: string | undefined) return connectionMode; } +/** + * Gets the display text for the database state returned from querying the database. + * @param state The state value returned from the database + */ +export function getDatabaseStateDisplayText(state: string): string { + switch (state.toUpperCase()) { + case 'ONLINE': + return loc.online; + case 'OFFLINE': + return loc.offline; + case 'RESTORING': + return loc.restoring; + case 'RECOVERING': + return loc.recovering; + case 'RECOVERY PENDING ': + return loc.recoveryPending; + case 'SUSPECT': + return loc.suspect; + case 'EMERGENCY': + return loc.emergecy; + } + return state; +} + /** * Opens an input box prompting the user to enter in the name of a resource to delete * @param namespace The namespace of the resource to delete @@ -79,12 +113,10 @@ export async function promptForResourceDeletion(namespace: string, name: string) inputBox.placeholder = name; return new Promise(resolve => { let valueAccepted = false; - inputBox.show(); inputBox.onDidAccept(() => { if (inputBox.value === name) { valueAccepted = true; inputBox.hide(); - inputBox.dispose(); resolve(true); } else { inputBox.validationMessage = loc.invalidResourceDeletionName(inputBox.value); @@ -94,10 +126,12 @@ export async function promptForResourceDeletion(namespace: string, name: string) if (!valueAccepted) { resolve(false); } + inputBox.dispose(); }); inputBox.onDidChangeValue(() => { inputBox.validationMessage = ''; }); + inputBox.show(); }); } @@ -105,13 +139,35 @@ export async function promptForResourceDeletion(namespace: string, name: string) * Gets the message to display for a given error object that may be a variety of types. * @param error The error object */ -export function getErrorText(error: any): string { +export function getErrorMessage(error: any): string { if (error?.body?.reason) { - // For HTTP Errors pull out the reason message since that's usually the most helpful + // For HTTP Errors with a body pull out the reason message since that's usually the most helpful return error.body.reason; - } else if (error instanceof Error) { + } else if (error.message) { + if (error.response?.statusMessage) { + // Some Http errors just have a status message as additional detail, but it's not enough on its + // own to be useful so append to the message as well + return `${error.message} (${error.response.statusMessage})`; + } return error.message; } else { return error; } } + +/** + * Parses an instance name from the controller. An instance name will either be just its name + * e.g. myinstance or namespace_name e.g. mynamespace_my-instance. + * @param instanceName The instance name in one of the formats described + */ +export function parseInstanceName(instanceName: string | undefined): string { + instanceName = instanceName ?? ''; + const parts: string[] = instanceName.split('_'); + if (parts.length === 2) { + instanceName = parts[1]; + } + else if (parts.length > 2) { + throw new Error(`Cannot parse resource '${instanceName}'. Acceptable formats are 'namespace_name' or 'name'.`); + } + return instanceName; +} diff --git a/extensions/arc/src/constants.ts b/extensions/arc/src/constants.ts index f20c9d4b1d..901aa348ea 100644 --- a/extensions/arc/src/constants.ts +++ b/extensions/arc/src/constants.ts @@ -31,6 +31,7 @@ export class IconPathHelper { public static support: IconPath; public static wrench: IconPath; public static miaa: IconPath; + public static controller: IconPath; public static setExtensionContext(context: vscode.ExtensionContext) { IconPathHelper.context = context; @@ -58,14 +59,6 @@ export class IconPathHelper { light: IconPathHelper.context.asAbsolutePath('images/copy.svg'), dark: IconPathHelper.context.asAbsolutePath('images/copy.svg') }; - IconPathHelper.collapseUp = { - light: IconPathHelper.context.asAbsolutePath('images/collapse-up.svg'), - dark: IconPathHelper.context.asAbsolutePath('images/collapse-up-inverse.svg') - }; - IconPathHelper.collapseDown = { - light: IconPathHelper.context.asAbsolutePath('images/collapse-down.svg'), - dark: IconPathHelper.context.asAbsolutePath('images/collapse-down-inverse.svg') - }; IconPathHelper.postgres = { light: IconPathHelper.context.asAbsolutePath('images/postgres.svg'), dark: IconPathHelper.context.asAbsolutePath('images/postgres.svg') @@ -106,6 +99,10 @@ export class IconPathHelper { light: context.asAbsolutePath('images/miaa.svg'), dark: context.asAbsolutePath('images/miaa.svg'), }; + IconPathHelper.controller = { + light: context.asAbsolutePath('images/data_controller.svg'), + dark: context.asAbsolutePath('images/data_controller.svg'), + }; } } diff --git a/extensions/arc/src/extension.ts b/extensions/arc/src/extension.ts index 58b4400ad9..24356eff55 100644 --- a/extensions/arc/src/extension.ts +++ b/extensions/arc/src/extension.ts @@ -3,7 +3,6 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as loc from './localizedConstants'; import { IconPathHelper } from './constants'; @@ -14,10 +13,33 @@ import { PostgresModel } from './models/postgresModel'; import { ControllerDashboard } from './ui/dashboards/controller/controllerDashboard'; import { MiaaDashboard } from './ui/dashboards/miaa/miaaDashboard'; import { MiaaModel } from './models/miaaModel'; +import { AzureArcTreeDataProvider } from './ui/tree/controllerTreeDataProvider'; +import { ControllerTreeNode } from './ui/tree/controllerTreeNode'; +import { TreeNode } from './ui/tree/treeNode'; export async function activate(context: vscode.ExtensionContext): Promise { IconPathHelper.setExtensionContext(context); + const treeDataProvider = new AzureArcTreeDataProvider(context); + vscode.window.registerTreeDataProvider('azureArc', treeDataProvider); + + vscode.commands.registerCommand('arc.addController', () => { + // Controller information + const controllerUrl = ''; + const auth = new BasicAuth('', ''); + + const controllerModel = new ControllerModel(controllerUrl, auth); + treeDataProvider.addController(controllerModel); + }); + + vscode.commands.registerCommand('arc.removeController', (controllerNode: ControllerTreeNode) => { + treeDataProvider.removeController(controllerNode); + }); + + vscode.commands.registerCommand('arc.openDashboard', async (treeNode: TreeNode) => { + await treeNode.openDashboard().catch(err => vscode.window.showErrorMessage(loc.openDashboardFailed(err))); + }); + vscode.commands.registerCommand('arc.manageArcController', async () => { // Controller information const controllerUrl = ''; @@ -40,28 +62,12 @@ export async function activate(context: vscode.ExtensionContext): Promise // Controller information const controllerUrl = ''; const auth = new BasicAuth('', ''); - const connection = await azdata.connection.openConnectionDialog(['MSSQL']); - const connectionProfile: azdata.IConnectionProfile = { - serverName: connection.options['serverName'], - databaseName: connection.options['databaseName'], - authenticationType: connection.options['authenticationType'], - providerName: 'MSSQL', - connectionName: '', - userName: connection.options['user'], - password: connection.options['password'], - savePassword: false, - groupFullName: undefined, - saveProfile: true, - id: connection.connectionId, - groupId: undefined, - options: connection.options - }; const instanceNamespace = ''; const instanceName = ''; try { const controllerModel = new ControllerModel(controllerUrl, auth); - const miaaModel = new MiaaModel(connectionProfile, controllerUrl, auth, instanceNamespace, instanceName); + const miaaModel = new MiaaModel(controllerUrl, auth, instanceNamespace, instanceName); const miaaDashboard = new MiaaDashboard(controllerModel, miaaModel); await Promise.all([ @@ -85,7 +91,7 @@ export async function activate(context: vscode.ExtensionContext): Promise try { const controllerModel = new ControllerModel(controllerUrl, auth); const postgresModel = new PostgresModel(controllerUrl, auth, dbNamespace, dbName); - const postgresDashboard = new PostgresDashboard(loc.postgresDashboard, context, controllerModel, postgresModel); + const postgresDashboard = new PostgresDashboard(context, controllerModel, postgresModel); await Promise.all([ postgresDashboard.showDashboard(), diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 752939bec6..071a1e94e2 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from 'vscode-nls'; +import { getErrorMessage } from './common/utils'; const localize = nls.loadMessageBundle(); export const arcControllerDashboard = localize('arc.controllerDashboard', "Azure Arc Controller Dashboard (Preview)"); @@ -64,6 +65,16 @@ export const clickTheNewSupportRequestButton = localize('arc.clickTheNewSupportR export const running = localize('arc.running', "Running"); export const connected = localize('arc.connected', "Connected"); export const disconnected = localize('arc.disconnected', "Disconnected"); +export const loading = localize('arc.loading', "Loading..."); + +// Database States - see https://docs.microsoft.com/sql/relational-databases/databases/database-states +export const online = localize('arc.online', "Online"); +export const offline = localize('arc.offline', "Offline"); +export const restoring = localize('arc.restoring', "Restoring"); +export const recovering = localize('arc.recovering', "Recovering"); +export const recoveryPending = localize('arc.recoveringPending', "Recovery Pending"); +export const suspect = localize('arc.suspect', "Suspect"); +export const emergecy = localize('arc.emergecy', "Emergecy"); // Postgres constants export const coordinatorEndpoint = localize('arc.coordinatorEndpoint', "Coordinator endpoint"); @@ -87,15 +98,16 @@ export const storagePerNode = localize('arc.storagePerNode', "storage per node") export const arcResources = localize('arc.arcResources', "Azure Arc Resources"); export function databaseCreated(name: string): string { return localize('arc.databaseCreated', "Database {0} created", name); } -export function databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database {0}. {1}", name, getErrorMessage(error)); } export function passwordReset(name: string): string { return localize('arc.passwordReset', "Password reset for service {0}", name); } -export function passwordResetFailed(name: string, error: any): string { return localize('arc.passwordResetFailed', "Failed to reset password for service {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function passwordResetFailed(name: string, error: any): string { return localize('arc.passwordResetFailed', "Failed to reset password for service {0}. {1}", name, getErrorMessage(error)); } export function resourceDeleted(name: string): string { return localize('arc.resourceDeleted', "Resource '{0}' deleted", name); } -export function resourceDeletionFailed(name: string, error: any): string { return localize('arc.resourceDeletionFailed', "Failed to delete resource {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function resourceDeletionFailed(name: string, error: any): string { return localize('arc.resourceDeletionFailed', "Failed to delete resource {0}. {1}", name, getErrorMessage(error)); } export function couldNotFindAzureResource(name: string): string { return localize('arc.couldNotFindAzureResource', "Could not find Azure resource for {0}", name); } export function copiedToClipboard(name: string): string { return localize('arc.copiedToClipboard', "{0} copied to clipboard", name); } -export function refreshFailed(error: any): string { return localize('arc.refreshFailed', "Refresh failed. {0}", (error instanceof Error ? error.message : error)); } -export function failedToManagePostgres(name: string, error: any): string { return localize('arc.failedToManagePostgres', "Failed to manage Postgres {0}. {1}", name, (error instanceof Error ? error.message : error)); } +export function refreshFailed(error: any): string { return localize('arc.refreshFailed', "Refresh failed. {0}", getErrorMessage(error)); } +export function failedToManagePostgres(name: string, error: any): string { return localize('arc.failedToManagePostgres', "Failed to manage Postgres {0}. {1}", name, getErrorMessage(error)); } +export function openDashboardFailed(error: any): string { return localize('arc.openDashboardFailed', "Error opening dashboard. {0}", getErrorMessage(error)); } export function clickTheTroubleshootButton(resourceType: string): string { return localize('arc.clickTheTroubleshootButton', "Click the troubleshoot button to open the Azure Arc {0} troubleshooting notebook.", resourceType); } export function numVCores(vCores: string): string { const numCores = +vCores; diff --git a/extensions/arc/src/models/controllerModel.ts b/extensions/arc/src/models/controllerModel.ts index 2edf5bd088..1d72e6e27e 100644 --- a/extensions/arc/src/models/controllerModel.ts +++ b/extensions/arc/src/models/controllerModel.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { Authentication } from '../controller/auth'; import { EndpointsRouterApi, EndpointModel, RegistrationRouterApi, RegistrationResponse, TokenRouterApi, SqlInstanceRouterApi } from '../controller/generated/v1/api'; -import { parseEndpoint } from '../common/utils'; +import { parseEndpoint, parseInstanceName } from '../common/utils'; import { ResourceType } from '../constants'; export interface Registration extends RegistrationResponse { @@ -31,7 +31,7 @@ export class ControllerModel { public endpointsLastUpdated?: Date; public registrationsLastUpdated?: Date; - constructor(controllerUrl: string, auth: Authentication) { + constructor(public readonly controllerUrl: string, public readonly auth: Authentication) { this._endpointsRouter = new EndpointsRouterApi(controllerUrl); this._endpointsRouter.setDefaultAuthentication(auth); @@ -84,16 +84,7 @@ export class ControllerModel { public getRegistration(type: string, namespace: string, name: string): Registration | undefined { return this._registrations.find(r => { - // Resources deployed outside the controller's namespace are named in the format 'namespace_name' - let instanceName = r.instanceName!; - const parts: string[] = instanceName.split('_'); - if (parts.length === 2) { - instanceName = parts[1]; - } - else if (parts.length > 2) { - throw new Error(`Cannot parse resource '${instanceName}'. Acceptable formats are 'namespace_name' or 'name'.`); - } - return r.instanceType === type && r.instanceNamespace === namespace && instanceName === name; + return r.instanceType === type && r.instanceNamespace === namespace && parseInstanceName(r.instanceName) === name; }); } diff --git a/extensions/arc/src/models/miaaModel.ts b/extensions/arc/src/models/miaaModel.ts index 5b9e8906b0..19b2723bde 100644 --- a/extensions/arc/src/models/miaaModel.ts +++ b/extensions/arc/src/models/miaaModel.ts @@ -16,6 +16,8 @@ export class MiaaModel { private _sqlInstanceRouter: SqlInstanceRouterApi; private _status: HybridSqlNsNameGetResponse | undefined; private _databases: DatabaseModel[] = []; + private _connectionProfile: azdata.IConnectionProfile | undefined = undefined; + private readonly _onPasswordUpdated = new vscode.EventEmitter(); private readonly _onStatusUpdated = new vscode.EventEmitter(); private readonly _onDatabasesUpdated = new vscode.EventEmitter(); @@ -24,7 +26,7 @@ export class MiaaModel { public onDatabasesUpdated = this._onDatabasesUpdated.event; public passwordLastUpdated?: Date; - constructor(public connectionProfile: azdata.IConnectionProfile, controllerUrl: string, auth: Authentication, private _namespace: string, private _name: string) { + constructor(controllerUrl: string, auth: Authentication, private _namespace: string, private _name: string) { this._sqlInstanceRouter = new SqlInstanceRouterApi(controllerUrl); this._sqlInstanceRouter.setDefaultAuthentication(auth); } @@ -43,6 +45,13 @@ export class MiaaModel { return this._namespace; } + /** + * The username used to connect to this instance + */ + public get username(): string | undefined { + return this._connectionProfile?.userName; + } + /** * The status of this instance */ @@ -67,17 +76,44 @@ export class MiaaModel { this._status = response.body; this._onStatusUpdated.fire(this._status); }); - const provider = azdata.dataprotocol.getProvider(this.connectionProfile.providerName, azdata.DataProviderType.MetadataProvider); - const databasesRefresh = azdata.connection.getUriForConnection(this.connectionProfile.id).then(ownerUri => { - provider.getDatabases(ownerUri).then(databases => { - if (databases.length > 0 && typeof (databases[0]) === 'object') { - this._databases = (databases).map(db => { return { name: db.options['name'], status: db.options['state'] }; }); - } else { - this._databases = (databases).map(db => { return { name: db, status: '-' }; }); - } - this._onDatabasesUpdated.fire(this._databases); + const promises: Thenable[] = [instanceRefresh]; + await this.getConnection(); + if (this._connectionProfile) { + const provider = azdata.dataprotocol.getProvider(this._connectionProfile.providerName, azdata.DataProviderType.MetadataProvider); + const databasesRefresh = azdata.connection.getUriForConnection(this._connectionProfile.id).then(ownerUri => { + provider.getDatabases(ownerUri).then(databases => { + if (databases.length > 0 && typeof (databases[0]) === 'object') { + this._databases = (databases).map(db => { return { name: db.options['name'], status: db.options['state'] }; }); + } else { + this._databases = (databases).map(db => { return { name: db, status: '-' }; }); + } + this._onDatabasesUpdated.fire(this._databases); + }); }); - }); - await Promise.all([instanceRefresh, databasesRefresh]); + promises.push(databasesRefresh); + } + await Promise.all(promises); + } + + private async getConnection(): Promise { + if (this._connectionProfile) { + return; + } + const connection = await azdata.connection.openConnectionDialog(['MSSQL']); + this._connectionProfile = { + serverName: connection.options['serverName'], + databaseName: connection.options['databaseName'], + authenticationType: connection.options['authenticationType'], + providerName: 'MSSQL', + connectionName: '', + userName: connection.options['user'], + password: connection.options['password'], + savePassword: false, + groupFullName: undefined, + saveProfile: true, + id: connection.connectionId, + groupId: undefined, + options: connection.options + }; } } diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaConnectionStringsPage.ts b/extensions/arc/src/ui/dashboards/miaa/miaaConnectionStringsPage.ts index 38b0db9e3b..389658e530 100644 --- a/extensions/arc/src/ui/dashboards/miaa/miaaConnectionStringsPage.ts +++ b/extensions/arc/src/ui/dashboards/miaa/miaaConnectionStringsPage.ts @@ -78,7 +78,7 @@ export class MiaaConnectionStringsPage extends DashboardPage { const ip = this._instanceRegistration.externalIp; const port = this._instanceRegistration.externalPort; - const username = this._miaaModel.connectionProfile.userName; + const username = this._miaaModel.username; const pairs: KeyValue[] = [ new InputKeyValue('ADO.NET', `Server=tcp:${ip},${port};Persist Security Info=False;User ID=${username};Password={your_password_here};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;`), diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts b/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts index 1ca1c473e7..502dffd6ec 100644 --- a/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts +++ b/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts @@ -9,7 +9,7 @@ import * as loc from '../../../localizedConstants'; import { DashboardPage } from '../../components/dashboardPage'; import { IconPathHelper, cssStyles, ResourceType } from '../../../constants'; import { ControllerModel, Registration } from '../../../models/controllerModel'; -import { getAzurecoreApi, promptForResourceDeletion, getErrorText } from '../../../common/utils'; +import { getAzurecoreApi, promptForResourceDeletion, getDatabaseStateDisplayText } from '../../../common/utils'; import { MiaaModel, DatabaseModel } from '../../../models/miaaModel'; import { HybridSqlNsNameGetResponse } from '../../../controller/generated/v1/model/hybridSqlNsNameGetResponse'; import { EndpointModel } from '../../../controller/generated/v1/api'; @@ -39,7 +39,7 @@ export class MiaaDashboardOverviewPage extends DashboardPage { constructor(modelView: azdata.ModelView, private _controllerModel: ControllerModel, private _miaaModel: MiaaModel) { super(modelView); - this._instanceProperties.miaaAdmin = this._miaaModel.connectionProfile.userName; + this._instanceProperties.miaaAdmin = this._miaaModel.username || this._instanceProperties.miaaAdmin; this._controllerModel.onRegistrationsUpdated((_: Registration[]) => { this.eventuallyRunOnInitialized(() => { this.handleRegistrationsUpdated().catch(e => console.log(e)); @@ -184,7 +184,7 @@ export class MiaaDashboardOverviewPage extends DashboardPage { vscode.window.showInformationMessage(loc.resourceDeleted(this._miaaModel.name)); } } catch (error) { - vscode.window.showErrorMessage(loc.resourceDeletionFailed(this._miaaModel.name, getErrorText(error))); + vscode.window.showErrorMessage(loc.resourceDeletionFailed(this._miaaModel.name, error)); } finally { deleteButton.enabled = true; } @@ -253,7 +253,10 @@ export class MiaaDashboardOverviewPage extends DashboardPage { } private handleDatabasesUpdated(databases: DatabaseModel[]): void { - this._databasesTable.data = databases.map(d => [d.name, d.status]); + // If we were able to get the databases it means we have a good connection so update the username too + this._instanceProperties.miaaAdmin = this._miaaModel.username || this._instanceProperties.miaaAdmin; + this.refreshDisplayedProperties(); + this._databasesTable.data = databases.map(d => [d.name, getDatabaseStateDisplayText(d.status)]); this._databasesTableLoading.loading = false; } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts index 6adcda34e2..0bb2b326c1 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresDashboard.ts @@ -19,8 +19,8 @@ import { PostgresDiagnoseAndSolveProblemsPage } from './postgresDiagnoseAndSolve import { PostgresSupportRequestPage } from './postgresSupportRequestPage'; export class PostgresDashboard extends Dashboard { - constructor(title: string, private _context: vscode.ExtensionContext, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { - super(title); + constructor(private _context: vscode.ExtensionContext, private _controllerModel: ControllerModel, private _postgresModel: PostgresModel) { + super(loc.postgresDashboard); } protected async registerTabs(modelView: azdata.ModelView): Promise<(azdata.DashboardTab | azdata.DashboardTabGroup)[]> { diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts index c15ae91889..5ef316c396 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts @@ -11,7 +11,7 @@ import { DuskyObjectModelsDatabase, DuskyObjectModelsDatabaseServiceArcPayload, import { DashboardPage } from '../../components/dashboardPage'; import { ControllerModel } from '../../../models/controllerModel'; import { PostgresModel, PodRole } from '../../../models/postgresModel'; -import { promptForResourceDeletion, getErrorText } from '../../../common/utils'; +import { promptForResourceDeletion } from '../../../common/utils'; export class PostgresOverviewPage extends DashboardPage { private propertiesLoading?: azdata.LoadingComponent; @@ -226,7 +226,7 @@ export class PostgresOverviewPage extends DashboardPage { vscode.window.showInformationMessage(loc.resourceDeleted(this._postgresModel.fullName)); } } catch (error) { - vscode.window.showErrorMessage(loc.resourceDeletionFailed(this._postgresModel.fullName, getErrorText(error))); + vscode.window.showErrorMessage(loc.resourceDeletionFailed(this._postgresModel.fullName, error)); } finally { deleteButton.enabled = true; } diff --git a/extensions/arc/src/ui/tree/controllerTreeDataProvider.ts b/extensions/arc/src/ui/tree/controllerTreeDataProvider.ts new file mode 100644 index 0000000000..77ea51cf49 --- /dev/null +++ b/extensions/arc/src/ui/tree/controllerTreeDataProvider.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ControllerTreeNode } from './controllerTreeNode'; +import { TreeNode } from './treeNode'; +import { LoadingControllerNode as LoadingTreeNode } from './loadingTreeNode'; +import { ControllerModel } from '../../models/controllerModel'; + +/** + * The TreeDataProvider for the Azure Arc view, which displays a list of registered + * controllers and the resources under them. + */ +export class AzureArcTreeDataProvider implements vscode.TreeDataProvider { + + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private _loading: boolean = true; + private _loadingNode = new LoadingTreeNode(); + + private _controllerNodes: ControllerTreeNode[] = []; + + constructor(private _context: vscode.ExtensionContext) { + // TODO: + setTimeout(() => { + this._loading = false; + this._onDidChangeTreeData.fire(undefined); + }, 5000); + } + + public async getChildren(element?: TreeNode): Promise { + if (this._loading) { + return [this._loadingNode]; + } + + if (element) { + return element.getChildren(); + } else { + return this._controllerNodes; + } + } + + public getTreeItem(element: TreeNode): TreeNode | Thenable { + return element; + } + + public addController(model: ControllerModel): void { + this._controllerNodes.push(new ControllerTreeNode(model, this._context)); + this._onDidChangeTreeData.fire(undefined); + } + + public removeController(controllerNode: ControllerTreeNode): void { + this._controllerNodes = this._controllerNodes.filter(node => node !== controllerNode); + this._onDidChangeTreeData.fire(undefined); + } +} diff --git a/extensions/arc/src/ui/tree/controllerTreeNode.ts b/extensions/arc/src/ui/tree/controllerTreeNode.ts new file mode 100644 index 0000000000..69d2a724da --- /dev/null +++ b/extensions/arc/src/ui/tree/controllerTreeNode.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { TreeNode } from './treeNode'; +import { MiaaTreeNode } from './miaaTreeNode'; +import { ResourceType } from '../../constants'; +import { PostgresTreeNode } from './postgresTreeNode'; +import { ControllerModel, Registration } from '../../models/controllerModel'; +import { ControllerDashboard } from '../dashboards/controller/controllerDashboard'; +import { PostgresModel } from '../../models/postgresModel'; +import { parseInstanceName } from '../../common/utils'; +import { MiaaModel } from '../../models/miaaModel'; + +/** + * The TreeNode for displaying an Azure Arc Controller + */ +export class ControllerTreeNode extends TreeNode { + + private _children: TreeNode[] = []; + + constructor(private _model: ControllerModel, private _context: vscode.ExtensionContext) { + super(_model.controllerUrl, vscode.TreeItemCollapsibleState.Collapsed, ResourceType.dataControllers); + _model.onRegistrationsUpdated(registrations => this.refreshChildren(registrations)); + _model.refresh().catch(err => console.log(`Error refreshing Arc Controller model for tree node : ${err}`)); + } + + public async getChildren(): Promise { + return this._children; + } + + public async openDashboard(): Promise { + const controllerDashboard = new ControllerDashboard(this._model); + await controllerDashboard.showDashboard(); + } + + private refreshChildren(registrations: Registration[]): void { + this._children = registrations.map(registration => { + if (!registration.instanceNamespace || !registration.instanceName) { + console.warn('Registration is missing required namespace and name values, skipping'); + return undefined; + } + switch (registration.instanceType) { + case ResourceType.postgresInstances: + const postgresModel = new PostgresModel(this._model.controllerUrl, this._model.auth, registration.instanceNamespace, parseInstanceName(registration.instanceName)); + return new PostgresTreeNode(postgresModel, this._model, this._context); + case ResourceType.sqlManagedInstances: + const miaaModel = new MiaaModel(this._model.controllerUrl, this._model.auth, registration.instanceNamespace, parseInstanceName(registration.instanceName)); + return new MiaaTreeNode(miaaModel, this._model); + } + return undefined; + }).filter(item => item); // filter out invalid nodes (controllers or ones without required properties) + } +} diff --git a/extensions/arc/src/ui/tree/loadingTreeNode.ts b/extensions/arc/src/ui/tree/loadingTreeNode.ts new file mode 100644 index 0000000000..4a4628a190 --- /dev/null +++ b/extensions/arc/src/ui/tree/loadingTreeNode.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as loc from '../../localizedConstants'; +import { TreeNode } from './treeNode'; + +/** + * A placeholder TreeNode to display while we're loading the initial set of stored nodes + */ +export class LoadingControllerNode extends TreeNode { + + constructor() { + super(loc.loading, vscode.TreeItemCollapsibleState.None, 'loading'); + } +} diff --git a/extensions/arc/src/ui/tree/miaaTreeNode.ts b/extensions/arc/src/ui/tree/miaaTreeNode.ts new file mode 100644 index 0000000000..bcb7ef1809 --- /dev/null +++ b/extensions/arc/src/ui/tree/miaaTreeNode.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ResourceType } from '../../constants'; +import { TreeNode } from './treeNode'; +import { MiaaModel } from '../../models/miaaModel'; +import { ControllerModel } from '../../models/controllerModel'; +import { MiaaDashboard } from '../dashboards/miaa/miaaDashboard'; + +/** + * The TreeNode for displaying a SQL Managed Instance on Azure Arc + */ +export class MiaaTreeNode extends TreeNode { + + constructor(private _model: MiaaModel, private _controllerModel: ControllerModel) { + super(_model.name, vscode.TreeItemCollapsibleState.None, ResourceType.sqlManagedInstances); + } + + public async openDashboard(): Promise { + const miaaDashboard = new MiaaDashboard(this._controllerModel, this._model); + await Promise.all([ + miaaDashboard.showDashboard(), + this._model.refresh()]); + } +} diff --git a/extensions/arc/src/ui/tree/postgresTreeNode.ts b/extensions/arc/src/ui/tree/postgresTreeNode.ts new file mode 100644 index 0000000000..c3409a917c --- /dev/null +++ b/extensions/arc/src/ui/tree/postgresTreeNode.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ResourceType } from '../../constants'; +import { TreeNode } from './treeNode'; +import { PostgresModel } from '../../models/postgresModel'; +import { ControllerModel } from '../../models/controllerModel'; +import { PostgresDashboard } from '../dashboards/postgres/postgresDashboard'; + +/** + * The TreeNode for displaying an Postgres Server group + */ +export class PostgresTreeNode extends TreeNode { + + constructor(private _model: PostgresModel, private _controllerModel: ControllerModel, private _context: vscode.ExtensionContext) { + super(_model.name, vscode.TreeItemCollapsibleState.None, ResourceType.postgresInstances); + } + + public async openDashboard(): Promise { + const postgresDashboard = new PostgresDashboard(this._context, this._controllerModel, this._model); + await Promise.all([ + postgresDashboard.showDashboard(), + this._model.refresh()]); + } +} diff --git a/extensions/arc/src/ui/tree/treeNode.ts b/extensions/arc/src/ui/tree/treeNode.ts new file mode 100644 index 0000000000..252e62ed63 --- /dev/null +++ b/extensions/arc/src/ui/tree/treeNode.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getResourceTypeIcon } from '../../common/utils'; + +/** + * The base class for a TreeNode to be displayed in the TreeView + */ +export abstract class TreeNode extends vscode.TreeItem { + constructor(label: string, collapsibleState: vscode.TreeItemCollapsibleState, private resourceType?: string) { + super(label, collapsibleState); + } + + public async getChildren(): Promise { + return []; + } + + public async openDashboard(): Promise { } + + iconPath = getResourceTypeIcon(this.resourceType); + contextValue = this.resourceType; +}