diff --git a/extensions/arc/coverConfig.json b/extensions/arc/coverConfig.json new file mode 100644 index 0000000000..cc192564eb --- /dev/null +++ b/extensions/arc/coverConfig.json @@ -0,0 +1,20 @@ +{ + "enabled": true, + "relativeSourcePath": "..", + "relativeCoverageDir": "../../coverage", + "ignorePatterns": [ + "**/generated/**", + "**/node_modules/**", + "**/test/**" + ], + "reports": [ + "cobertura", + "lcov", + "json" + ], + "verbose": false, + "remapOptions": { + "basePath": "..", + "useAbsolutePaths": true + } +} diff --git a/extensions/arc/package.json b/extensions/arc/package.json index f49bf2846b..e595ef8ebe 100644 --- a/extensions/arc/package.json +++ b/extensions/arc/package.json @@ -49,13 +49,22 @@ "title": "%arc.openDashboard%" }, { - "command": "arc.addController", - "title": "%command.addController.title%", + "command": "arc.createController", + "title": "%command.createController.title%", "icon": "$(add)" }, + { + "command": "arc.connectToController", + "title": "%command.connectToController.title%", + "icon": "$(debug-disconnect)" + }, { "command": "arc.removeController", "title": "%command.removeController.title%" + }, + { + "command": "arc.refresh", + "title": "%command.refresh.title%" } ], "menus": { @@ -79,13 +88,22 @@ { "command": "arc.removeController", "when": "false" + }, + { + "command": "arc.refresh", + "when": "false" } ], "view/title": [ { - "command": "arc.addController", + "command": "arc.createController", "when": "view == azureArc", - "group": "navigation" + "group": "navigation@1" + }, + { + "command": "arc.connectToController", + "when": "view == azureArc", + "group": "navigation@2" } ], "view/item/context": [ @@ -95,9 +113,14 @@ "group": "navigation@1" }, { - "command": "arc.removeController", + "command": "arc.refresh", "when": "view == azureArc && viewItem == dataControllers", "group": "navigation@2" + }, + { + "command": "arc.removeController", + "when": "view == azureArc && viewItem == dataControllers", + "group": "navigation@3" } ] }, @@ -125,6 +148,7 @@ "mocha-junit-reporter": "^1.17.0", "mocha-multi-reporters": "^1.1.7", "should": "^13.2.3", + "typemoq": "2.1.0", "vscodetestcover": "^1.0.9" } } diff --git a/extensions/arc/package.nls.json b/extensions/arc/package.nls.json index 3158a8c03a..fdf3452a14 100644 --- a/extensions/arc/package.nls.json +++ b/extensions/arc/package.nls.json @@ -7,7 +7,9 @@ "arc.managePostgres": "Manage Postgres", "arc.manageArcController": "Manage Arc Controller", "arc.view.title" : "Azure Arc Controllers", - "command.addController.title": "Connect to Controller", + "command.createController.title" : "Create New Controller", + "command.connectToController.title": "Connect to Existing Controller", "command.removeController.title": "Remove Controller", + "command.refresh.title": "Refresh", "arc.openDashboard": "Manage" } diff --git a/extensions/arc/src/constants.ts b/extensions/arc/src/constants.ts index 901aa348ea..d65a51e4ab 100644 --- a/extensions/arc/src/constants.ts +++ b/extensions/arc/src/constants.ts @@ -5,6 +5,8 @@ import * as vscode from 'vscode'; +export const refreshActionId = 'arc.refresh'; + export interface IconPath { dark: string; light: string; diff --git a/extensions/arc/src/extension.ts b/extensions/arc/src/extension.ts index 24356eff55..b01f205777 100644 --- a/extensions/arc/src/extension.ts +++ b/extensions/arc/src/extension.ts @@ -5,17 +5,11 @@ import * as vscode from 'vscode'; import * as loc from './localizedConstants'; -import { IconPathHelper } from './constants'; -import { BasicAuth } from './controller/auth'; -import { PostgresDashboard } from './ui/dashboards/postgres/postgresDashboard'; -import { ControllerModel } from './models/controllerModel'; -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 { IconPathHelper, refreshActionId } from './constants'; +import { AzureArcTreeDataProvider } from './ui/tree/azureArcTreeDataProvider'; import { ControllerTreeNode } from './ui/tree/controllerTreeNode'; import { TreeNode } from './ui/tree/treeNode'; +import { ConnectToControllerDialog } from './ui/dialogs/connectControllerDialog'; export async function activate(context: vscode.ExtensionContext): Promise { IconPathHelper.setExtensionContext(context); @@ -23,85 +17,30 @@ export async function activate(context: vscode.ExtensionContext): Promise 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.createController', async () => { + await vscode.commands.executeCommand('azdata.resource.deploy'); }); - vscode.commands.registerCommand('arc.removeController', (controllerNode: ControllerTreeNode) => { - treeDataProvider.removeController(controllerNode); + vscode.commands.registerCommand('arc.connectToController', async () => { + const dialog = new ConnectToControllerDialog(treeDataProvider); + dialog.showDialog(); + const model = await dialog.waitForClose(); + if (model) { + await treeDataProvider.addOrUpdateController(model.controllerModel, model.password); + } + }); + + vscode.commands.registerCommand('arc.removeController', async (controllerNode: ControllerTreeNode) => { + await treeDataProvider.removeController(controllerNode); + }); + + vscode.commands.registerCommand(refreshActionId, async (treeNode: TreeNode) => { + treeDataProvider.refreshNode(treeNode); }); 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 = ''; - const auth = new BasicAuth('', ''); - - try { - const controllerModel = new ControllerModel(controllerUrl, auth); - const controllerDashboard = new ControllerDashboard(controllerModel); - - await Promise.all([ - controllerDashboard.showDashboard(), - controllerModel.refresh() - ]); - } catch (error) { - // vscode.window.showErrorMessage(loc.failedToManagePostgres(`${dbNamespace}.${dbName}`, error)); - } - }); - - vscode.commands.registerCommand('arc.manageMiaa', async () => { - // Controller information - const controllerUrl = ''; - const auth = new BasicAuth('', ''); - const instanceNamespace = ''; - const instanceName = ''; - - try { - const controllerModel = new ControllerModel(controllerUrl, auth); - const miaaModel = new MiaaModel(controllerUrl, auth, instanceNamespace, instanceName); - const miaaDashboard = new MiaaDashboard(controllerModel, miaaModel); - - await Promise.all([ - miaaDashboard.showDashboard(), - controllerModel.refresh() - ]); - } catch (error) { - // vscode.window.showErrorMessage(loc.failedToManagePostgres(`${dbNamespace}.${dbName}`, error)); - } - }); - - vscode.commands.registerCommand('arc.managePostgres', async () => { - // Controller information - const controllerUrl = ''; - const auth = new BasicAuth('', ''); - - // Postgres information - const dbNamespace = ''; - const dbName = ''; - - try { - const controllerModel = new ControllerModel(controllerUrl, auth); - const postgresModel = new PostgresModel(controllerUrl, auth, dbNamespace, dbName); - const postgresDashboard = new PostgresDashboard(context, controllerModel, postgresModel); - - await Promise.all([ - postgresDashboard.showDashboard(), - controllerModel.refresh(), - postgresModel.refresh() - ]); - } catch (error) { - vscode.window.showErrorMessage(loc.failedToManagePostgres(`${dbNamespace}.${dbName}`, error)); - } - }); } export function deactivate(): void { diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index 071a1e94e2..d994530d6f 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -66,6 +66,14 @@ 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..."); +export const refreshToEnterCredentials = localize('arc.refreshToEnterCredentials', "Refresh node to enter credentials"); +export const connectToController = localize('arc.connectToController', "Connect to Existing Controller"); +export const controllerUrl = localize('arc.controllerUrl', "Controller URL"); +export const username = localize('arc.username', "Username"); +export const password = localize('arc.password', "Password"); +export const rememberPassword = localize('arc.rememberPassword', "Remember Password"); +export const connect = localize('arc.connect', "Connect"); +export const cancel = localize('arc.cancel', "Cancel"); // Database States - see https://docs.microsoft.com/sql/relational-databases/databases/database-states export const online = localize('arc.online', "Online"); @@ -98,16 +106,10 @@ 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, 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, 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, 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}", 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; @@ -120,3 +122,11 @@ export function numVCores(vCores: string): string { export function couldNotFindRegistration(namespace: string, name: string) { return localize('arc.couldNotFindRegistration', "Could not find controller registration for {0} ({1})", name, namespace); } export function resourceDeletionWarning(namespace: string, name: string): string { return localize('arc.resourceDeletionWarning', "Warning! Deleting a resource is permanent and cannot be undone. To delete the resource '{0}.{1}' type the name '{1}' below to proceed.", namespace, name); } export function invalidResourceDeletionName(name: string): string { return localize('arc.invalidResourceDeletionName', "The value '{0}' does not match the instance name. Try again or press escape to exit", name); } + +// Errors +export function refreshFailed(error: any): string { return localize('arc.refreshFailed', "Refresh failed. {0}", getErrorMessage(error)); } +export function openDashboardFailed(error: any): string { return localize('arc.openDashboardFailed', "Error opening dashboard. {0}", getErrorMessage(error)); } +export function resourceDeletionFailed(name: string, error: any): string { return localize('arc.resourceDeletionFailed', "Failed to delete resource {0}. {1}", name, getErrorMessage(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 databaseCreationFailed(name: string, error: any): string { return localize('arc.databaseCreationFailed', "Failed to create database {0}. {1}", name, getErrorMessage(error)); } +export function connectToControllerFailed(url: string, error: any): string { return localize('arc.connectToControllerFailed', "Could not connect to controller {0}. {1}", url, getErrorMessage(error)); } diff --git a/extensions/arc/src/models/controllerModel.ts b/extensions/arc/src/models/controllerModel.ts index 1d72e6e27e..a5d007e554 100644 --- a/extensions/arc/src/models/controllerModel.ts +++ b/extensions/arc/src/models/controllerModel.ts @@ -4,10 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { Authentication } from '../controller/auth'; +import { Authentication, BasicAuth } from '../controller/auth'; import { EndpointsRouterApi, EndpointModel, RegistrationRouterApi, RegistrationResponse, TokenRouterApi, SqlInstanceRouterApi } from '../controller/generated/v1/api'; import { parseEndpoint, parseInstanceName } from '../common/utils'; import { ResourceType } from '../constants'; +import { ConnectToControllerDialog } from '../ui/dialogs/connectControllerDialog'; +import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider'; + +export type ControllerInfo = { + url: string, + username: string, + rememberPassword: boolean +}; export interface Registration extends RegistrationResponse { externalIp?: string; @@ -23,6 +31,7 @@ export class ControllerModel { private _namespace: string = ''; private _registrations: Registration[] = []; private _controllerRegistration: Registration | undefined = undefined; + private _auth: Authentication | undefined = undefined; private readonly _onEndpointsUpdated = new vscode.EventEmitter(); private readonly _onRegistrationsUpdated = new vscode.EventEmitter(); @@ -30,22 +39,42 @@ export class ControllerModel { public onRegistrationsUpdated = this._onRegistrationsUpdated.event; public endpointsLastUpdated?: Date; public registrationsLastUpdated?: Date; + public get auth(): Authentication | undefined { + return this._auth; + } - constructor(public readonly controllerUrl: string, public readonly auth: Authentication) { - this._endpointsRouter = new EndpointsRouterApi(controllerUrl); - this._endpointsRouter.setDefaultAuthentication(auth); - - this._tokenRouter = new TokenRouterApi(controllerUrl); - this._tokenRouter.setDefaultAuthentication(auth); - - this._registrationRouter = new RegistrationRouterApi(controllerUrl); - this._registrationRouter.setDefaultAuthentication(auth); - - this._sqlInstanceRouter = new SqlInstanceRouterApi(controllerUrl); - this._sqlInstanceRouter.setDefaultAuthentication(auth); + constructor(private _treeDataProvider: AzureArcTreeDataProvider, public info: ControllerInfo, password?: string) { + this._endpointsRouter = new EndpointsRouterApi(this.info.url); + this._tokenRouter = new TokenRouterApi(this.info.url); + this._registrationRouter = new RegistrationRouterApi(this.info.url); + this._sqlInstanceRouter = new SqlInstanceRouterApi(this.info.url); + if (password) { + this.setAuthentication(new BasicAuth(this.info.username, password)); + } } public async refresh(): Promise { + // We haven't gotten our password yet, fetch it now + if (!this._auth) { + let password = ''; + if (this.info.rememberPassword) { + // It should be in the credentials store, get it from there + password = await this._treeDataProvider.getPassword(this.info); + } + if (password) { + this.setAuthentication(new BasicAuth(this.info.username, password)); + } else { + // No password yet so prompt for it from the user + const dialog = new ConnectToControllerDialog(this._treeDataProvider); + dialog.showDialog(this.info); + const model = await dialog.waitForClose(); + if (model) { + this._treeDataProvider.addOrUpdateController(model.controllerModel, model.password, false); + this.setAuthentication(new BasicAuth(this.info.username, model.password)); + } + } + + } await Promise.all([ this._endpointsRouter.apiV1BdcEndpointsGet().then(response => { this._endpoints = response.body; @@ -88,9 +117,31 @@ export class ControllerModel { }); } + /** + * Deletes the specified MIAA resource from the controller + * @param namespace The namespace of the resource + * @param name The name of the resource + */ public async miaaDelete(namespace: string, name: string): Promise { await this._sqlInstanceRouter.apiV1HybridSqlNsNameDelete(namespace, name); } + + /** + * Tests whether this model is for the same controller as another + * @param other The other instance to test + */ + public equals(other: ControllerModel): boolean { + return this.info.url === other.info.url && + this.info.username === other.info.username; + } + + private setAuthentication(auth: Authentication): void { + this._auth = auth; + this._endpointsRouter.setDefaultAuthentication(auth); + this._tokenRouter.setDefaultAuthentication(auth); + this._registrationRouter.setDefaultAuthentication(auth); + this._sqlInstanceRouter.setDefaultAuthentication(auth); + } } /** diff --git a/extensions/arc/src/test/common/utils.test.ts b/extensions/arc/src/test/common/utils.test.ts index 272af9aadc..0a5f873f67 100644 --- a/extensions/arc/src/test/common/utils.test.ts +++ b/extensions/arc/src/test/common/utils.test.ts @@ -5,7 +5,7 @@ import * as should from 'should'; import 'mocha'; -import { resourceTypeToDisplayName, parseEndpoint } from '../../common/utils'; +import { resourceTypeToDisplayName, parseEndpoint, parseInstanceName } from '../../common/utils'; import * as loc from '../../localizedConstants'; import { ResourceType } from '../../constants'; @@ -44,3 +44,20 @@ describe('parseEndpoint Method Tests', () => { }); }); +describe('parseInstanceName Method Tests', () => { + it('Should parse valid instanceName with namespace correctly', function (): void { + should(parseInstanceName('mynamespace_myinstance')).equal('myinstance'); + }); + + it('Should parse valid instanceName without namespace correctly', function (): void { + should(parseInstanceName('myinstance')).equal('myinstance'); + }); + + it('Should return empty string when undefined value passed in', function (): void { + should(parseInstanceName(undefined)).equal(''); + }); + + it('Should return empty string when empty string value passed in', function (): void { + should(parseInstanceName('')).equal(''); + }); +}); diff --git a/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts b/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts new file mode 100644 index 0000000000..5d93ff2ae9 --- /dev/null +++ b/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * 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 should from 'should'; +import 'mocha'; +import * as TypeMoq from 'typemoq'; +import { AzureArcTreeDataProvider } from '../../../ui/tree/azureArcTreeDataProvider'; +import { ControllerModel } from '../../../models/controllerModel'; +import { ControllerTreeNode } from '../../../ui/tree/controllerTreeNode'; +import { LoadingControllerNode } from '../../../ui/tree/loadingTreeNode'; + +describe('AzureArcTreeDataProvider tests', function (): void { + let treeDataProvider: AzureArcTreeDataProvider; + beforeEach(function (): void { + const mockExtensionContext = TypeMoq.Mock.ofType(); + const mockGlobalState = TypeMoq.Mock.ofType(); + mockGlobalState.setup(x => x.update(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve()); + mockExtensionContext.setup(x => x.globalState).returns(() => mockGlobalState.object); + //treeDataProviderMock = TypeMoq.Mock.ofType(); + treeDataProvider = new AzureArcTreeDataProvider(mockExtensionContext.object); + }); + + describe('addOrUpdateController', function (): void { + it('Multiple Controllers are added correctly', async function (): Promise { + treeDataProvider['_loading'] = false; + let children = await treeDataProvider.getChildren(); + should(children.length).equal(0, 'There initially shouldn\'t be any children'); + const controllerModelMock = TypeMoq.Mock.ofType(); + await treeDataProvider.addOrUpdateController(controllerModelMock.object, ''); + should(children.length).equal(1, 'Controller node should be added correctly'); + + // Add a couple more + const controllerModelMock2 = TypeMoq.Mock.ofType(); + const controllerModelMock3 = TypeMoq.Mock.ofType(); + await treeDataProvider.addOrUpdateController(controllerModelMock2.object, ''); + await treeDataProvider.addOrUpdateController(controllerModelMock3.object, ''); + should(children.length).equal(3, 'Additional Controller nodes should be added correctly'); + }); + + it('Adding a Controller more than once doesn\'t create duplicates', async function (): Promise { + treeDataProvider['_loading'] = false; + let children = await treeDataProvider.getChildren(); + should(children.length).equal(0, 'There initially shouldn\'t be any children'); + const controllerModel = new ControllerModel(treeDataProvider, { url: '127.0.0.1', username: 'sa', rememberPassword: true }); + await treeDataProvider.addOrUpdateController(controllerModel, ''); + should(children.length).equal(1, 'Controller node should be added correctly'); + await treeDataProvider.addOrUpdateController(controllerModel, ''); + should(children.length).equal(1, 'Shouldn\'t add duplicate controller node'); + }); + + it('Updating an existing controller works as expected', async function (): Promise { + treeDataProvider['_loading'] = false; + let children = await treeDataProvider.getChildren(); + should(children.length).equal(0, 'There initially shouldn\'t be any children'); + const controllerModel = new ControllerModel(treeDataProvider, { url: '127.0.0.1', username: 'sa', rememberPassword: true }); + await treeDataProvider.addOrUpdateController(controllerModel, ''); + should(children.length).equal(1, 'Controller node should be added correctly'); + should((children[0]).model.info.rememberPassword).be.true('Info was not set correctly initially'); + const controllerModel2 = new ControllerModel(treeDataProvider, { url: '127.0.0.1', username: 'sa', rememberPassword: false }); + await treeDataProvider.addOrUpdateController(controllerModel2, ''); + should(children.length).equal(1, 'Shouldn\'t add duplicate controller node'); + should((children[0]).model.info.rememberPassword).be.false('Info was not updated correctly'); + }); + }); + + describe('getChildren', function (): void { + it('should return a loading node before loading stored controllers is completed', async function (): Promise { + treeDataProvider['_loading'] = true; + let children = await treeDataProvider.getChildren(); + should(children.length).equal(1, 'While loading we should return the loading node'); + should(children[0] instanceof LoadingControllerNode).be.true('Node returned was not a LoadingControllerNode'); + }); + + it('should return no children after loading', async function (): Promise { + treeDataProvider['_loading'] = false; + let children = await treeDataProvider.getChildren(); + should(children.length).equal(0, 'After loading we should have 0 children'); + }); + }); + + describe('removeController', function (): void { + it('removing a controller should work as expected', async function (): Promise { + treeDataProvider['_loading'] = false; + const controllerModel = new ControllerModel(treeDataProvider, { url: '127.0.0.1', username: 'sa', rememberPassword: true }); + const controllerModel2 = new ControllerModel(treeDataProvider, { url: '127.0.0.2', username: 'cloudsa', rememberPassword: true }); + await treeDataProvider.addOrUpdateController(controllerModel, ''); + await treeDataProvider.addOrUpdateController(controllerModel2, ''); + const children = (await treeDataProvider.getChildren()); + await treeDataProvider.removeController(children[0]); + should((await treeDataProvider.getChildren()).length).equal(1, 'Node should have been removed'); + await treeDataProvider.removeController(children[0]); + should((await treeDataProvider.getChildren()).length).equal(1, 'Removing same node again should do nothing'); + await treeDataProvider.removeController(children[1]); + should((await treeDataProvider.getChildren()).length).equal(0, 'Removing other node should work'); + await treeDataProvider.removeController(children[1]); + should((await treeDataProvider.getChildren()).length).equal(0, 'Removing other node again should do nothing'); + }); + }); +}); diff --git a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts new file mode 100644 index 0000000000..2172392252 --- /dev/null +++ b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as loc from '../../localizedConstants'; +import { AzureArcTreeDataProvider } from '../tree/azureArcTreeDataProvider'; +import { ControllerModel, ControllerInfo } from '../../models/controllerModel'; +import { Deferred } from '../../common/promise'; + +export type ConnectToControllerDialogModel = { controllerModel: ControllerModel, password: string }; + +export class ConnectToControllerDialog { + private modelBuilder!: azdata.ModelBuilder; + + private urlInputBox!: azdata.InputBoxComponent; + private usernameInputBox!: azdata.InputBoxComponent; + private passwordInputBox!: azdata.InputBoxComponent; + private rememberPwCheckBox!: azdata.CheckBoxComponent; + + private _completionPromise = new Deferred(); + + constructor(private _treeDataProvider: AzureArcTreeDataProvider) { } + + public showDialog(controllerInfo?: ControllerInfo): void { + const dialog = azdata.window.createModelViewDialog(loc.connectToController); + dialog.cancelButton.onClick(() => this.handleCancel()); + dialog.registerContent(async view => { + this.modelBuilder = view.modelBuilder; + + this.urlInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: controllerInfo?.url, + // If we have a model then we're editing an existing connection so don't let them modify the URL + readOnly: !!controllerInfo + }).component(); + this.usernameInputBox = this.modelBuilder.inputBox() + .withProperties({ + value: controllerInfo?.username + }).component(); + this.passwordInputBox = this.modelBuilder.inputBox() + .withProperties({ + inputType: 'password', + }) + .component(); + this.rememberPwCheckBox = this.modelBuilder.checkBox() + .withProperties({ + label: loc.rememberPassword, + checked: controllerInfo?.rememberPassword + }).component(); + + let formModel = this.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: this.urlInputBox, + title: loc.controllerUrl, + required: true + }, { + component: this.usernameInputBox, + title: loc.username, + required: true + }, { + component: this.passwordInputBox, + title: loc.password, + required: true + }, { + component: this.rememberPwCheckBox, + title: '' + } + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await view.initializeModel(formModel); + this.urlInputBox.focus(); + }); + + dialog.registerCloseValidator(async () => await this.validate()); + dialog.okButton.label = loc.connect; + dialog.cancelButton.label = loc.cancel; + azdata.window.openDialog(dialog); + } + + private async validate(): Promise { + if (!this.urlInputBox.value || !this.usernameInputBox.value || !this.passwordInputBox.value) { + return false; + } + const controllerInfo: ControllerInfo = { url: this.urlInputBox.value, username: this.usernameInputBox.value, rememberPassword: this.rememberPwCheckBox.checked ?? false }; + const controllerModel = new ControllerModel(this._treeDataProvider, controllerInfo, this.passwordInputBox.value); + try { + // Validate that we can connect to the controller + await controllerModel.refresh(); + } catch (err) { + vscode.window.showErrorMessage(loc.connectToControllerFailed(this.urlInputBox.value, err)); + return false; + } + this._completionPromise.resolve({ controllerModel: controllerModel, password: this.passwordInputBox.value }); + return true; + } + + private handleCancel(): void { + this._completionPromise.resolve(undefined); + } + + public waitForClose(): Promise { + return this._completionPromise.promise; + } +} diff --git a/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts b/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts new file mode 100644 index 0000000000..163793fdf4 --- /dev/null +++ b/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { ControllerTreeNode } from './controllerTreeNode'; +import { TreeNode } from './treeNode'; +import { LoadingControllerNode as LoadingTreeNode } from './loadingTreeNode'; +import { ControllerModel, ControllerInfo } from '../../models/controllerModel'; + +const mementoToken = 'arcControllers'; + +/** + * 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 _credentialsProvider = azdata.credentials.getProvider('arcControllerPasswords'); + 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) { + this.loadSavedControllers().catch(err => console.log(`Error loading saved Arc controllers ${err}`)); + } + + 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 async addOrUpdateController(model: ControllerModel, password: string, refreshTree = true): Promise { + const controllerNode = this._controllerNodes.find(node => model.equals(node.model)); + if (controllerNode) { + controllerNode.model.info = model.info; + } else { + this._controllerNodes.push(new ControllerTreeNode(model, this._context)); + } + await this.updatePassword(model, password); + if (refreshTree) { + this._onDidChangeTreeData.fire(undefined); + } + await this.saveControllers(); + } + + public async removeController(controllerNode: ControllerTreeNode): Promise { + this._controllerNodes = this._controllerNodes.filter(node => node !== controllerNode); + this._onDidChangeTreeData.fire(undefined); + await this.saveControllers(); + } + + public async getPassword(info: ControllerInfo): Promise { + const provider = await this._credentialsProvider; + const credential = await provider.readCredential(getCredentialId(info)); + return credential.password; + } + + /** + * Refreshes the specified node, or the entire tree if node is undefined + * @param node The node to refresh, or undefined for the whole tree + */ + public refreshNode(node: TreeNode | undefined): void { + this._onDidChangeTreeData.fire(node); + } + + private async updatePassword(model: ControllerModel, password: string): Promise { + const provider = await this._credentialsProvider; + if (model.info.rememberPassword) { + provider.saveCredential(getCredentialId(model.info), password); + } else { + provider.deleteCredential(getCredentialId(model.info)); + } + } + + private async loadSavedControllers(): Promise { + try { + const controllerMementos: ControllerInfo[] = this._context.globalState.get(mementoToken) || []; + this._controllerNodes = controllerMementos.map(memento => { + const controllerModel = new ControllerModel(this, memento); + return new ControllerTreeNode(controllerModel, this._context); + }); + } finally { + this._loading = false; + this._onDidChangeTreeData.fire(undefined); + } + } + + private async saveControllers(): Promise { + await this._context.globalState.update(mementoToken, this._controllerNodes.map(node => node.model.info)); + } +} + +function getCredentialId(info: ControllerInfo): string { + return `${info.url}::${info.username}`; +} diff --git a/extensions/arc/src/ui/tree/controllerTreeDataProvider.ts b/extensions/arc/src/ui/tree/controllerTreeDataProvider.ts deleted file mode 100644 index 77ea51cf49..0000000000 --- a/extensions/arc/src/ui/tree/controllerTreeDataProvider.ts +++ /dev/null @@ -1,59 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 index 69d2a724da..7266be75ea 100644 --- a/extensions/arc/src/ui/tree/controllerTreeNode.ts +++ b/extensions/arc/src/ui/tree/controllerTreeNode.ts @@ -13,6 +13,8 @@ import { ControllerDashboard } from '../dashboards/controller/controllerDashboar import { PostgresModel } from '../../models/postgresModel'; import { parseInstanceName } from '../../common/utils'; import { MiaaModel } from '../../models/miaaModel'; +import { Deferred } from '../../common/promise'; +import { RefreshTreeNode } from './refreshTreeNode'; /** * The TreeNode for displaying an Azure Arc Controller @@ -20,19 +22,31 @@ import { MiaaModel } from '../../models/miaaModel'; export class ControllerTreeNode extends TreeNode { private _children: TreeNode[] = []; + private _childrenRefreshPromise = new Deferred(); - 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}`)); + constructor(public model: ControllerModel, private _context: vscode.ExtensionContext) { + super(model.info.url, vscode.TreeItemCollapsibleState.Collapsed, ResourceType.dataControllers); + model.onRegistrationsUpdated(registrations => this.refreshChildren(registrations)); } public async getChildren(): Promise { + // First reset our deferred promise so we're sure we'll get the refreshed children + this._childrenRefreshPromise = new Deferred(); + try { + await this.model.refresh(); + await this._childrenRefreshPromise.promise; + } catch (err) { + // Couldn't get the children and TreeView doesn't have a way to collapse a node + // in a way that will refetch its children when expanded again so instead we + // display a tempory node that will prompt the user to re-enter credentials + return [new RefreshTreeNode(this)]; + } + return this._children; } public async openDashboard(): Promise { - const controllerDashboard = new ControllerDashboard(this._model); + const controllerDashboard = new ControllerDashboard(this.model); await controllerDashboard.showDashboard(); } @@ -44,13 +58,14 @@ export class ControllerTreeNode extends TreeNode { } 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); + const postgresModel = new PostgresModel(this.model.info.url, 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); + const miaaModel = new MiaaModel(this.model.info.url, 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) + this._childrenRefreshPromise.resolve(); } } diff --git a/extensions/arc/src/ui/tree/refreshTreeNode.ts b/extensions/arc/src/ui/tree/refreshTreeNode.ts new file mode 100644 index 0000000000..7fee0d54f1 --- /dev/null +++ b/extensions/arc/src/ui/tree/refreshTreeNode.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 * as loc from '../../localizedConstants'; +import { TreeNode } from './treeNode'; +import { refreshActionId } from '../../constants'; + +/** + * A placeholder TreeNode to display when credentials weren't entered + */ +export class RefreshTreeNode extends TreeNode { + + constructor(private _parent: TreeNode) { + super(loc.refreshToEnterCredentials, vscode.TreeItemCollapsibleState.None, 'refresh'); + } + + public command: vscode.Command = { + command: refreshActionId, + title: loc.refreshToEnterCredentials, + arguments: [this._parent] + }; +} diff --git a/extensions/arc/yarn.lock b/extensions/arc/yarn.lock index 084d3832c8..3eaeb0d14f 100644 --- a/extensions/arc/yarn.lock +++ b/extensions/arc/yarn.lock @@ -322,6 +322,11 @@ charenc@~0.0.1: resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= +circular-json@^0.3.1: + version "0.3.3" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" + integrity sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -711,7 +716,7 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" -lodash@^4.16.4, lodash@^4.17.13: +lodash@^4.16.4, lodash@^4.17.13, lodash@^4.17.4: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -861,6 +866,11 @@ pify@^4.0.1: resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== +postinstall-build@^5.0.1: + version "5.0.3" + resolved "https://registry.yarnpkg.com/postinstall-build/-/postinstall-build-5.0.3.tgz#238692f712a481d8f5bc8960e94786036241efc7" + integrity sha512-vPvPe8TKgp4FLgY3+DfxCE5PIfoXBK2lyLfNCxsRbDsV6vS4oU5RG/IWxrblMn6heagbnMED3MemUQllQ2bQUg== + psl@^1.1.28: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" @@ -1068,6 +1078,15 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= +typemoq@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/typemoq/-/typemoq-2.1.0.tgz#4452ce360d92cf2a1a180f0c29de2803f87af1e8" + integrity sha512-DtRNLb7x8yCTv/KHlwes+NI+aGb4Vl1iPC63Hhtcvk1DpxSAZzKWQv0RQFY0jX2Uqj0SDBNl8Na4e6MV6TNDgw== + dependencies: + circular-json "^0.3.1" + lodash "^4.17.4" + postinstall-build "^5.0.1" + uri-js@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" diff --git a/scripts/test-extensions-unit.bat b/scripts/test-extensions-unit.bat index 82bc294277..2b47da500b 100755 --- a/scripts/test-extensions-unit.bat +++ b/scripts/test-extensions-unit.bat @@ -52,6 +52,11 @@ echo *** starting agent tests *** echo **************************** call "%INTEGRATION_TEST_ELECTRON_PATH%" --extensionDevelopmentPath=%~dp0\..\extensions\agent --extensionTestsPath=%~dp0\..\extensions\agent\out\test --user-data-dir=%VSCODEUSERDATADIR% --extensions-dir=%VSCODEEXTENSIONSDIR% --remote-debugging-port=9222 --disable-telemetry --disable-crash-reporter --disable-updates --nogpu +echo ************************** +echo *** starting arc tests *** +echo ************************** +call "%INTEGRATION_TEST_ELECTRON_PATH%" --extensionDevelopmentPath=%~dp0\..\extensions\arc --extensionTestsPath=%~dp0\..\extensions\arc\out\test --user-data-dir=%VSCODEUSERDATADIR% --extensions-dir=%VSCODEEXTENSIONSDIR% --remote-debugging-port=9222 --disable-telemetry --disable-crash-reporter --disable-updates --nogpu + echo ******************************** echo *** starting azurecore tests *** echo ******************************** diff --git a/scripts/test-extensions-unit.sh b/scripts/test-extensions-unit.sh index 08e26a2af5..53d08d9236 100755 --- a/scripts/test-extensions-unit.sh +++ b/scripts/test-extensions-unit.sh @@ -49,6 +49,11 @@ echo *** starting agent tests *** echo **************************** "$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_NO_SANDBOX --extensionDevelopmentPath=$ROOT/extensions/agent --extensionTestsPath=$ROOT/extensions/agent/out/test --user-data-dir=$VSCODEUSERDATADIR --extensions-dir=$VSCODEEXTDIR --disable-telemetry --disable-crash-reporter --disable-updates --nogpu +echo ************************** +echo *** starting arc tests *** +echo ************************** +"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_NO_SANDBOX --extensionDevelopmentPath=$ROOT/extensions/arc --extensionTestsPath=$ROOT/extensions/arc/out/test --user-data-dir=$VSCODEUSERDATADIR --extensions-dir=$VSCODEEXTDIR --disable-telemetry --disable-crash-reporter --disable-updates --nogpu + echo ******************************** echo *** starting azurecore tests *** echo ********************************