diff --git a/extensions/arc/src/common/kubeUtils.ts b/extensions/arc/src/common/kubeUtils.ts index 71d551ce50..7fc69603f3 100644 --- a/extensions/arc/src/common/kubeUtils.ts +++ b/extensions/arc/src/common/kubeUtils.ts @@ -13,6 +13,11 @@ export interface KubeClusterContext { isCurrentContext: boolean; } +/** + * returns the cluster context defined in the {@see configFile} + * + * @param configFile + */ export function getKubeConfigClusterContexts(configFile: string): Promise { const config: any = yamljs.load(configFile); const rawContexts = config['contexts']; @@ -33,6 +38,38 @@ export function getKubeConfigClusterContexts(configFile: string): Promise c.name === previousClusterContext)) { // if previous cluster context value is found in clusters then return that value + return previousClusterContext; + } else { + if (throwIfNotFound) { + throw new Error(loc.clusterContextNotFound(previousClusterContext)); + } + } + } + + // if not previousClusterContext or throwIfNotFound was false when previousCLusterContext was not found in the clusterContexts + const currentClusterContext = clusterContexts.find(c => c.isCurrentContext)?.name; + throwUnless(currentClusterContext !== undefined, loc.noCurrentClusterContext); + return currentClusterContext; +} + +/** + * returns the default kube config file path + */ export function getDefaultKubeConfigPath(): string { return path.join(os.homedir(), '.kube', 'config'); } diff --git a/extensions/arc/src/localizedConstants.ts b/extensions/arc/src/localizedConstants.ts index a17906bb0e..55185a8c7a 100644 --- a/extensions/arc/src/localizedConstants.ts +++ b/extensions/arc/src/localizedConstants.ts @@ -61,7 +61,7 @@ export const yes = localize('arc.yes', "Yes"); export const no = localize('arc.no', "No"); export const feedback = localize('arc.feedback', "Feedback"); export const selectConnectionString = localize('arc.selectConnectionString', "Select from available client connection strings below."); -export const addingWokerNodes = localize('arc.addingWokerNodes', "adding worker nodes"); +export const addingWorkerNodes = localize('arc.addingWorkerNodes', "adding worker nodes"); export const workerNodesDescription = localize('arc.workerNodesDescription', "Expand your server group and scale your database by adding worker nodes."); export const postgresConfigurationInformation = localize('arc.postgres.configurationInformation', "You can configure the number of CPU cores and storage size that will apply to both worker nodes and coordinator node. Each worker node will have the same configuration. Adjust the number of CPU cores and memory settings for your server group."); export const workerNodesInformation = localize('arc.workerNodeInformation', "In preview it is not possible to reduce the number of worker nodes. Please refer to documentation linked above for more information."); @@ -85,6 +85,8 @@ export const passwordToController = localize('arc.passwordToController', "Provid export const controllerUrl = localize('arc.controllerUrl', "Controller URL"); export const serverEndpoint = localize('arc.serverEndpoint', "Server Endpoint"); export const controllerName = localize('arc.controllerName', "Name"); +export const controllerKubeConfig = localize('arc.controllerKubeConfig', "Kube Config File Path"); +export const controllerClusterContext = localize('arc.controllerClusterContext', "Cluster Context"); export const defaultControllerName = localize('arc.defaultControllerName', "arc-dc"); export const username = localize('arc.username', "Username"); export const password = localize('arc.password', "Password"); @@ -202,6 +204,10 @@ export const variableValueFetchForUnsupportedVariable = (variableName: string) = export const isPasswordFetchForUnsupportedVariable = (variableName: string) => localize('getIsPassword.unknownVariableName', "Attempt to get isPassword for unknown variable:{0}", variableName); export const noControllerInfoFound = (name: string) => localize('noControllerInfoFound', "Controller Info could not be found with name: {0}", name); export const noPasswordFound = (controllerName: string) => localize('noPasswordFound', "Password could not be retrieved for controller: {0} and user did not provide a password. Please retry later.", controllerName); +export const clusterContextNotFound = (clusterContext: string) => localize('clusterContextNotFound', "Cluster Context with name: {0} not found in the Kube config file", clusterContext); +export const noCurrentClusterContext = localize('noCurrentClusterContext', "No current cluster context was found in the kube config file"); +export const browse = localize('filePicker.browse', "Browse"); +export const select = localize('button.label', "Select"); export const noContextFound = (configFile: string) => localize('noContextFound', "No 'contexts' found in the config file: {0}", configFile); export const noCurrentContextFound = (configFile: string) => localize('noCurrentContextFound', "No context is marked as 'current-context' in the config file: {0}", configFile); export const noNameInContext = (configFile: string) => localize('noNameInContext', "No name field was found in a cluster context in the config file: {0}", configFile); diff --git a/extensions/arc/src/test/common/kubeUtils.test.ts b/extensions/arc/src/test/common/kubeUtils.test.ts index ecd9988f70..b3c224ac3e 100644 --- a/extensions/arc/src/test/common/kubeUtils.test.ts +++ b/extensions/arc/src/test/common/kubeUtils.test.ts @@ -55,7 +55,7 @@ describe('KubeUtils', function (): void { }); it('throws error when unable to load config file', async () => { const error = new Error('unknown error accessing file'); - sinon.stub(yamljs, 'load').throws(error); //erroring config file load + sinon.stub(yamljs, 'load').throws(error); // simulate an error thrown from config file load ((await tryExecuteAction(() => getKubeConfigClusterContexts(configFile))).error).should.equal(error, `test: getKubeConfigClusterContexts failed`); }); }); diff --git a/extensions/arc/src/test/mocks/fakeControllerModel.ts b/extensions/arc/src/test/mocks/fakeControllerModel.ts index 9c19a0de14..0696e48da6 100644 --- a/extensions/arc/src/test/mocks/fakeControllerModel.ts +++ b/extensions/arc/src/test/mocks/fakeControllerModel.ts @@ -11,7 +11,7 @@ import { AzureArcTreeDataProvider } from '../../ui/tree/azureArcTreeDataProvider export class FakeControllerModel extends ControllerModel { constructor(treeDataProvider?: AzureArcTreeDataProvider, info?: Partial, password?: string) { - const _info: ControllerInfo = Object.assign({ id: uuid(), url: '', name: '', username: '', rememberPassword: false, resources: [] }, info); + const _info: ControllerInfo = Object.assign({ id: uuid(), url: '', kubeConfigFilePath: '', kubeClusterContext: '', name: '', username: '', rememberPassword: false, resources: [] }, info); super(treeDataProvider!, _info, password); } diff --git a/extensions/arc/src/test/models/controllerModel.test.ts b/extensions/arc/src/test/models/controllerModel.test.ts index b3247b2da9..99381fcc73 100644 --- a/extensions/arc/src/test/models/controllerModel.test.ts +++ b/extensions/arc/src/test/models/controllerModel.test.ts @@ -39,7 +39,7 @@ describe('ControllerModel', function (): void { it('Rejected with expected error when user cancels', async function (): Promise { // Returning an undefined model here indicates that the dialog closed without clicking "Ok" - usually through the user clicking "Cancel" sinon.stub(ConnectToControllerDialog.prototype, 'waitForClose').returns(Promise.resolve(undefined)); - const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); + const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); await should(model.azdataLogin()).be.rejectedWith(new UserCancelledError(loc.userCancelledError)); }); @@ -58,7 +58,7 @@ describe('ControllerModel', function (): void { azdataMock.setup(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); azdataExtApiMock.setup(x => x.azdata).returns(() => azdataMock.object); sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); - const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); + const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); await model.azdataLogin(); azdataMock.verify(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password), TypeMoq.Times.once()); @@ -81,10 +81,10 @@ describe('ControllerModel', function (): void { sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); // Set up dialog to return new model with our password - const newModel = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, password); + const newModel = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, password); sinon.stub(ConnectToControllerDialog.prototype, 'waitForClose').returns(Promise.resolve({ controllerModel: newModel, password: password })); - const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); + const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); await model.azdataLogin(); azdataMock.verify(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password), TypeMoq.Times.once()); @@ -106,10 +106,10 @@ describe('ControllerModel', function (): void { sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); // Set up dialog to return new model with our new password from the reprompt - const newModel = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, password); + const newModel = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, password); const waitForCloseStub = sinon.stub(ConnectToControllerDialog.prototype, 'waitForClose').returns(Promise.resolve({ controllerModel: newModel, password: password })); - const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); + const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); await model.azdataLogin(true); should(waitForCloseStub.called).be.true('waitForClose should have been called'); @@ -132,11 +132,11 @@ describe('ControllerModel', function (): void { sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); // Set up dialog to return new model with our new password from the reprompt - const newModel = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, password); + const newModel = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, password); const waitForCloseStub = sinon.stub(ConnectToControllerDialog.prototype, 'waitForClose').returns(Promise.resolve({ controllerModel: newModel, password: password })); // Set up original model with a password - const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, 'originalPassword'); + const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, 'originalPassword'); await model.azdataLogin(true); should(waitForCloseStub.called).be.true('waitForClose should have been called'); @@ -166,6 +166,8 @@ describe('ControllerModel', function (): void { { id: uuid(), url: '127.0.0.1', + kubeConfigFilePath: '/path/to/.kube/config', + kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: false, @@ -178,6 +180,8 @@ describe('ControllerModel', function (): void { const newInfo: ControllerInfo = { id: model.info.id, // The ID stays the same since we're just re-entering information for the same model url: 'newUrl', + kubeConfigFilePath: '/path/to/.kube/config', + kubeClusterContext: 'currentCluster', username: 'newUser', name: 'newName', rememberPassword: true, diff --git a/extensions/arc/src/test/stubs.ts b/extensions/arc/src/test/stubs.ts index 22a7e49a62..741478bc21 100644 --- a/extensions/arc/src/test/stubs.ts +++ b/extensions/arc/src/test/stubs.ts @@ -7,21 +7,44 @@ import * as azdata from 'azdata'; import * as TypeMoq from 'typemoq'; import * as vscode from 'vscode'; -export function createModelViewMock() { +interface ModelViewMocks { + mockModelView: TypeMoq.IMock, + mockModelBuilder: TypeMoq.IMock, + mockTextBuilder: TypeMoq.IMock>, + mockInputBoxBuilder: TypeMoq.IMock>, + mockButtonBuilder: TypeMoq.IMock>, + mockRadioButtonBuilder: TypeMoq.IMock>, + mockDivBuilder: TypeMoq.IMock, + mockFlexBuilder: TypeMoq.IMock, + mockLoadingBuilder: TypeMoq.IMock +} + +export function createModelViewMock(buttonClickEmitter?: vscode.EventEmitter): ModelViewMocks { const mockModelBuilder = TypeMoq.Mock.ofType(); const mockTextBuilder = setupMockComponentBuilder(); const mockInputBoxBuilder = setupMockComponentBuilder(); + buttonClickEmitter = buttonClickEmitter ?? new vscode.EventEmitter(); + const mockButtonBuilder = setupMockButtonBuilderWithClickEmitter(buttonClickEmitter); const mockRadioButtonBuilder = setupMockComponentBuilder(); const mockDivBuilder = setupMockContainerBuilder(); + const mockFlexBuilder = setupMockContainerBuilder(); const mockLoadingBuilder = setupMockLoadingBuilder(); mockModelBuilder.setup(b => b.loadingComponent()).returns(() => mockLoadingBuilder.object); mockModelBuilder.setup(b => b.text()).returns(() => mockTextBuilder.object); mockModelBuilder.setup(b => b.inputBox()).returns(() => mockInputBoxBuilder.object); + mockModelBuilder.setup(b => b.button()).returns(() => mockButtonBuilder.object); mockModelBuilder.setup(b => b.radioButton()).returns(() => mockRadioButtonBuilder.object); mockModelBuilder.setup(b => b.divContainer()).returns(() => mockDivBuilder.object); + mockModelBuilder.setup(b => b.flexContainer()).returns(() => mockFlexBuilder.object); const mockModelView = TypeMoq.Mock.ofType(); mockModelView.setup(mv => mv.modelBuilder).returns(() => mockModelBuilder.object); - return { mockModelView, mockModelBuilder, mockTextBuilder, mockInputBoxBuilder, mockRadioButtonBuilder, mockDivBuilder }; + return { mockModelView, mockModelBuilder, mockTextBuilder, mockInputBoxBuilder, mockButtonBuilder, mockRadioButtonBuilder, mockDivBuilder, mockFlexBuilder, mockLoadingBuilder }; +} + +function setupMockButtonBuilderWithClickEmitter(buttonClickEmitter: vscode.EventEmitter): TypeMoq.IMock> { + const { mockComponentBuilder: mockButtonBuilder, mockComponent: mockButtonComponent } = setupMockComponentBuilderAndComponent(); + mockButtonComponent.setup(b => b.onDidClick(TypeMoq.It.isAny())).returns(buttonClickEmitter.event); + return mockButtonBuilder; } function setupMockLoadingBuilder( @@ -39,26 +62,44 @@ export function setupMockComponentBuilder, ): TypeMoq.IMock { mockComponentBuilder = mockComponentBuilder ?? TypeMoq.Mock.ofType(); - const returnComponent = TypeMoq.Mock.ofType(); - // Need to setup 'then' for when a mocked object is resolved otherwise the test will hang : https://github.com/florinn/typemoq/issues/66 - returnComponent.setup((x: any) => x.then).returns(() => { }); + setupMockComponentBuilderAndComponent(mockComponentBuilder, componentGetter); + return mockComponentBuilder; +} + +function setupMockComponentBuilderAndComponent = azdata.ComponentBuilder>( + mockComponentBuilder?: TypeMoq.IMock, + componentGetter?: ((props: P) => T) +): { mockComponentBuilder: TypeMoq.IMock, mockComponent: TypeMoq.IMock } { + mockComponentBuilder = mockComponentBuilder ?? TypeMoq.Mock.ofType(); + const mockComponent = createComponentMock(); let compProps: P; mockComponentBuilder.setup(b => b.withProperties(TypeMoq.It.isAny())).callback((props: P) => compProps = props).returns(() => mockComponentBuilder!.object); mockComponentBuilder.setup(b => b.component()).returns(() => { - return componentGetter ? componentGetter(compProps) : Object.assign(Object.assign({}, returnComponent.object), compProps); + return componentGetter ? componentGetter(compProps) : Object.assign(Object.assign({}, mockComponent.object), compProps); }); // For now just have these be passthrough - can hook up additional functionality later if needed mockComponentBuilder.setup(b => b.withValidation(TypeMoq.It.isAny())).returns(() => mockComponentBuilder!.object); - return mockComponentBuilder; + return { mockComponentBuilder, mockComponent }; +} + +function createComponentMock(): TypeMoq.IMock { + const mockComponent = TypeMoq.Mock.ofType(); + // Need to setup 'then' for when a mocked object is resolved otherwise the test will hang : https://github.com/florinn/typemoq/issues/66 + mockComponent.setup((x: any) => x.then).returns(() => { }); + return mockComponent; } export function setupMockContainerBuilder, P extends azdata.ComponentProperties, B extends azdata.ContainerBuilder = azdata.ContainerBuilder>( mockContainerBuilder?: TypeMoq.IMock ): TypeMoq.IMock { - mockContainerBuilder = mockContainerBuilder ?? setupMockComponentBuilder(); + const items: azdata.Component[] = []; + const mockContainer = createComponentMock(); // T is azdata.Container type so this creates a azdata.Container mock + mockContainer.setup(c => c.items).returns(() => items); + mockContainerBuilder = mockContainerBuilder ?? setupMockComponentBuilder((_props) => mockContainer.object); + + mockContainerBuilder.setup(b => b.withItems(TypeMoq.It.isAny(), TypeMoq.It.isAny())).callback((_items, _itemsStyle) => items.push(..._items)).returns(() => mockContainerBuilder!.object); // For now just have these be passthrough - can hook up additional functionality later if needed - mockContainerBuilder.setup(b => b.withItems(TypeMoq.It.isAny(), undefined)).returns(() => mockContainerBuilder!.object); mockContainerBuilder.setup(b => b.withLayout(TypeMoq.It.isAny())).returns(() => mockContainerBuilder!.object); return mockContainerBuilder; } diff --git a/extensions/arc/src/test/ui/components/filePicker.test.ts b/extensions/arc/src/test/ui/components/filePicker.test.ts new file mode 100644 index 0000000000..3aa3ddf8bf --- /dev/null +++ b/extensions/arc/src/test/ui/components/filePicker.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 should from 'should'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { Deferred } from '../../../common/promise'; +import { FilePicker } from '../../../ui/components/filePicker'; +import { createModelViewMock } from '../../stubs'; + +let filePicker: FilePicker; +const initialPath = '/path/to/.kube/config'; +const newFilePath = '/path/to/new/.kube/config'; +let filePathInputBox: azdata.InputBoxComponent; +let browseButton: azdata.ButtonComponent; +let flexContainer: azdata.FlexContainer; +const browseButtonEmitter = new vscode.EventEmitter(); +describe('filePicker', function (): void { + beforeEach(async () => { + const { mockModelBuilder, mockInputBoxBuilder, mockButtonBuilder, mockFlexBuilder } = createModelViewMock(browseButtonEmitter); + filePicker = new FilePicker(mockModelBuilder.object, initialPath, (_disposable) => { }); + filePathInputBox = mockInputBoxBuilder.object.component(); + browseButton = mockButtonBuilder.object.component(); + flexContainer = mockFlexBuilder.object.component(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('browse Button chooses new FilePath', async () => { + should(filePathInputBox.value).should.not.be.undefined(); + filePicker.value!.should.equal(initialPath); + flexContainer.items.should.deepEqual([filePathInputBox, browseButton]); + const deferred = new Deferred(); + sinon.stub(vscode.window, 'showOpenDialog').callsFake(async (_options) => { + deferred.resolve(); + return [vscode.Uri.file(newFilePath)]; + }); + browseButtonEmitter.fire(undefined); //simulate the click of the browseButton + await deferred; + filePicker.value!.should.equal(newFilePath); + }); + + describe('getters and setters', async () => { + it('component getter', () => { + should(filePicker.component()).equal(flexContainer); + }); + [true, false].forEach(testValue => { + it(`Test readOnly with testValue: ${testValue}`, () => { + filePicker.readOnly = testValue; + filePicker.readOnly!.should.equal(testValue); + }); + it(`Test enabled with testValue: ${testValue}`, () => { + filePicker.enabled = testValue; + filePicker.enabled!.should.equal(testValue); + }); + }); + }); +}); + + + diff --git a/extensions/arc/src/test/ui/components/radioOptionsGroup.test.ts b/extensions/arc/src/test/ui/components/radioOptionsGroup.test.ts index 2db4d4cccc..9d9c931e83 100644 --- a/extensions/arc/src/test/ui/components/radioOptionsGroup.test.ts +++ b/extensions/arc/src/test/ui/components/radioOptionsGroup.test.ts @@ -21,11 +21,11 @@ const radioOptionsInfo = { }; const divItems: azdata.Component[] = []; let radioOptionsGroup: RadioOptionsGroup; - +let loadingComponent: azdata.LoadingComponent; describe('radioOptionsGroup', function (): void { beforeEach(async () => { - const { mockModelView, mockRadioButtonBuilder, mockDivBuilder } = createModelViewMock(); + const { mockModelBuilder, mockRadioButtonBuilder, mockDivBuilder, mockLoadingBuilder } = createModelViewMock(); mockRadioButtonBuilder.reset(); // reset any previous mock so that we can set our own. setupMockComponentBuilder( (props) => new FakeRadioButton(props), @@ -41,8 +41,9 @@ describe('radioOptionsGroup', function (): void { }, mockDivBuilder ); - radioOptionsGroup = new RadioOptionsGroup(mockModelView.object, (_disposable) => { }); + radioOptionsGroup = new RadioOptionsGroup(mockModelBuilder.object, (_disposable) => { }); await radioOptionsGroup.load(async () => radioOptionsInfo); + loadingComponent = mockLoadingBuilder.object.component(); }); it('verify construction and load', async () => { @@ -72,6 +73,23 @@ describe('radioOptionsGroup', function (): void { should(label.CSSStyles!.color).not.be.undefined(); label.CSSStyles!.color.should.equal('Red'); }); + + describe('getters and setters', async () => { + it(`component getter`, () => { + radioOptionsGroup.component().should.deepEqual(loadingComponent); + }); + + [true, false].forEach(testValue => { + it(`Test readOnly with testValue: ${testValue}`, () => { + radioOptionsGroup.readOnly = testValue; + radioOptionsGroup.readOnly!.should.equal(testValue); + }); + it(`Test enabled with testValue: ${testValue}`, () => { + radioOptionsGroup.enabled = testValue; + radioOptionsGroup.enabled!.should.equal(testValue); + }); + }); + }); }); function verifyRadioGroup() { diff --git a/extensions/arc/src/test/ui/dialogs/connectControllerDialog.test.ts b/extensions/arc/src/test/ui/dialogs/connectControllerDialog.test.ts index 2b8df03beb..b06bd463b4 100644 --- a/extensions/arc/src/test/ui/dialogs/connectControllerDialog.test.ts +++ b/extensions/arc/src/test/ui/dialogs/connectControllerDialog.test.ts @@ -32,7 +32,7 @@ describe('ConnectControllerDialog', function (): void { it('validate returns false if controller refresh fails', async function (): Promise { sinon.stub(ControllerModel.prototype, 'refresh').returns(Promise.reject('Controller refresh failed')); const connectControllerDialog = new ConnectToControllerDialog(undefined!); - const info = { id: uuid(), url: 'https://127.0.0.1:30080', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }; + const info = { id: uuid(), url: 'https://127.0.0.1:30080', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }; connectControllerDialog.showDialog(info, 'pwd'); await connectControllerDialog.isInitialized; const validateResult = await connectControllerDialog.validate(); @@ -41,36 +41,36 @@ describe('ConnectControllerDialog', function (): void { it('validate replaces http with https', async function (): Promise { await validateConnectControllerDialog( - { id: uuid(), url: 'http://127.0.0.1:30081', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, + { id: uuid(), url: 'http://127.0.0.1:30081', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, 'https://127.0.0.1:30081'); }); it('validate appends https if missing', async function (): Promise { - await validateConnectControllerDialog({ id: uuid(), url: '127.0.0.1:30080', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, + await validateConnectControllerDialog({ id: uuid(), url: '127.0.0.1:30080', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, 'https://127.0.0.1:30080'); }); it('validate appends default port if missing', async function (): Promise { - await validateConnectControllerDialog({ id: uuid(), url: 'https://127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, + await validateConnectControllerDialog({ id: uuid(), url: 'https://127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, 'https://127.0.0.1:30080'); }); it('validate appends both port and https if missing', async function (): Promise { - await validateConnectControllerDialog({ id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, + await validateConnectControllerDialog({ id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, 'https://127.0.0.1:30080'); }); for (const name of ['', undefined]) { it.skip(`validate display name gets set to arc instance name for user chosen name of:${name}`, async function (): Promise { await validateConnectControllerDialog( - { id: uuid(), url: 'http://127.0.0.1:30081', name: name!, username: 'sa', rememberPassword: true, resources: [] }, + { id: uuid(), url: 'http://127.0.0.1:30081', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: name!, username: 'sa', rememberPassword: true, resources: [] }, 'https://127.0.0.1:30081'); }); } it.skip(`validate display name gets set to default data controller name for user chosen name of:'' and instanceName in explicably returned as undefined from the controller endpoint`, async function (): Promise { await validateConnectControllerDialog( - { id: uuid(), url: 'http://127.0.0.1:30081', name: '', username: 'sa', rememberPassword: true, resources: [] }, + { id: uuid(), url: 'http://127.0.0.1:30081', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: '', username: 'sa', rememberPassword: true, resources: [] }, 'https://127.0.0.1:30081', undefined); }); diff --git a/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts b/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts index 3d429de41a..ef6cc14cd2 100644 --- a/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts +++ b/extensions/arc/src/test/ui/tree/azureArcTreeDataProvider.test.ts @@ -53,7 +53,7 @@ describe('AzureArcTreeDataProvider tests', function (): void { 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, { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); + const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); await treeDataProvider.addOrUpdateController(controllerModel, ''); should(children.length).equal(1, 'Controller node should be added correctly'); await treeDataProvider.addOrUpdateController(controllerModel, ''); @@ -64,12 +64,12 @@ describe('AzureArcTreeDataProvider tests', function (): void { treeDataProvider['_loading'] = false; let children = await treeDataProvider.getChildren(); should(children.length).equal(0, 'There initially shouldn\'t be any children'); - const originalInfo: ControllerInfo = { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }; + const originalInfo: ControllerInfo = { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }; const controllerModel = new ControllerModel(treeDataProvider, originalInfo); await treeDataProvider.addOrUpdateController(controllerModel, ''); should(children.length).equal(1, 'Controller node should be added correctly'); should((children[0]).model.info).deepEqual(originalInfo); - const newInfo = { id: originalInfo.id, url: '1.1.1.1', name: 'new-name', username: 'admin', rememberPassword: false, resources: [] }; + const newInfo = { id: originalInfo.id, url: '1.1.1.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'new-name', username: 'admin', rememberPassword: false, resources: [] }; const controllerModel2 = new ControllerModel(treeDataProvider, newInfo); await treeDataProvider.addOrUpdateController(controllerModel2, ''); should(children.length).equal(1, 'Shouldn\'t add duplicate controller node'); @@ -102,7 +102,7 @@ describe('AzureArcTreeDataProvider tests', function (): void { mockArcApi.setup(x => x.azdata).returns(() => fakeAzdataApi); sinon.stub(vscode.extensions, 'getExtension').returns(mockArcExtension.object); - const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, 'mypassword'); + const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }, 'mypassword'); await treeDataProvider.addOrUpdateController(controllerModel, ''); const controllerNode = treeDataProvider.getControllerNode(controllerModel); const children = await treeDataProvider.getChildren(controllerNode); @@ -115,8 +115,8 @@ describe('AzureArcTreeDataProvider tests', function (): void { describe('removeController', function (): void { it('removing a controller should work as expected', async function (): Promise { treeDataProvider['_loading'] = false; - const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); - const controllerModel2 = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.2', name: 'my-arc', username: 'cloudsa', rememberPassword: true, resources: [] }); + const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); + const controllerModel2 = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.2', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'cloudsa', rememberPassword: true, resources: [] }); await treeDataProvider.addOrUpdateController(controllerModel, ''); await treeDataProvider.addOrUpdateController(controllerModel2, ''); const children = (await treeDataProvider.getChildren()); @@ -133,20 +133,20 @@ describe('AzureArcTreeDataProvider tests', function (): void { describe('openResourceDashboard', function (): void { it('Opening dashboard for nonexistent controller node throws', async function (): Promise { - const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); + const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); const openDashboardPromise = treeDataProvider.openResourceDashboard(controllerModel, ResourceType.sqlManagedInstances, ''); await should(openDashboardPromise).be.rejected(); }); it('Opening dashboard for nonexistent resource throws', async function (): Promise { - const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); + const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); await treeDataProvider.addOrUpdateController(controllerModel, ''); const openDashboardPromise = treeDataProvider.openResourceDashboard(controllerModel, ResourceType.sqlManagedInstances, ''); await should(openDashboardPromise).be.rejected(); }); it('Opening dashboard for existing resource node succeeds', async function (): Promise { - const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); + const controllerModel = new ControllerModel(treeDataProvider, { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', name: 'my-arc', username: 'sa', rememberPassword: true, resources: [] }); const miaaModel = new MiaaModel(controllerModel, { name: 'miaa-1', resourceType: ResourceType.sqlManagedInstances }, undefined!, treeDataProvider); await treeDataProvider.addOrUpdateController(controllerModel, ''); const controllerNode = treeDataProvider.getControllerNode(controllerModel)!; diff --git a/extensions/arc/src/typings/arc.d.ts b/extensions/arc/src/typings/arc.d.ts index 0fcc14c352..090990d39c 100644 --- a/extensions/arc/src/typings/arc.d.ts +++ b/extensions/arc/src/typings/arc.d.ts @@ -31,6 +31,8 @@ declare module 'arc' { export type ControllerInfo = { id: string, + kubeConfigFilePath: string, + kubeClusterContext: string url: string, name: string, username: string, diff --git a/extensions/arc/src/ui/components/filePicker.ts b/extensions/arc/src/ui/components/filePicker.ts new file mode 100644 index 0000000000..cebba3bdd0 --- /dev/null +++ b/extensions/arc/src/ui/components/filePicker.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * 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 path from 'path'; +import * as vscode from 'vscode'; +import * as loc from '../../localizedConstants'; +import { IReadOnly } from '../dialogs/connectControllerDialog'; + +export interface RadioOptionsInfo { + values?: string[], + defaultValue: string +} + +export class FilePicker implements IReadOnly { + private _flexContainer: azdata.FlexContainer; + private _filePathInputBox: azdata.InputBoxComponent; + private _filePickerButton: azdata.ButtonComponent; + constructor( + modelBuilder: azdata.ModelBuilder, + initialPath: string, onNewDisposableCreated: (disposable: vscode.Disposable) => void + ) { + const buttonWidth = 80; + this._filePathInputBox = modelBuilder.inputBox() + .withProperties({ + value: initialPath, + width: 350 + }).component(); + + this._filePickerButton = modelBuilder.button() + .withProperties({ + label: loc.browse, + width: buttonWidth + }).component(); + onNewDisposableCreated(this._filePickerButton.onDidClick(async () => { + const fileUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: this._filePathInputBox.value ? vscode.Uri.file(path.dirname(this._filePathInputBox.value)) : undefined, + openLabel: loc.select, + filters: undefined /* file type filters */ + }); + + if (!fileUris || fileUris.length === 0) { + return; // This can happen when a user cancels out. we don't throw and the user just won't be able to move on until they select something. + } + const fileUri = fileUris[0]; //we allow the user to select only one file in the dialog + this._filePathInputBox.value = fileUri.fsPath; + })); + this._flexContainer = createFlexContainer(modelBuilder, [this._filePathInputBox, this._filePickerButton]); + } + + component(): azdata.Component { + return this._flexContainer; + } + + get onTextChanged() { + return this._filePathInputBox.onTextChanged; + } + + get value(): string | undefined { + return this._filePathInputBox?.value; + } + + get readOnly(): boolean { + return this.enabled; + } + + set readOnly(value: boolean) { + this.enabled = value; + } + + get enabled(): boolean { + return !!this._flexContainer.enabled && this._flexContainer.items.every(r => r.enabled); + } + + set enabled(value: boolean) { + this._flexContainer.items.forEach(r => r.enabled = value); + this._flexContainer.enabled = value; + } +} + +function createFlexContainer(modelBuilder: azdata.ModelBuilder, items: azdata.Component[], rowLayout: boolean = true, width?: string | number, height?: string | number, alignItems?: azdata.AlignItemsType, cssStyles?: { [key: string]: string }): azdata.FlexContainer { + const flexFlow = rowLayout ? 'row' : 'column'; + alignItems = alignItems || (rowLayout ? 'center' : undefined); + const itemsStyle = rowLayout ? { CSSStyles: { 'margin-right': '5px', } } : {}; + const flexLayout: azdata.FlexLayout = { flexFlow: flexFlow, height: height, width: width, alignItems: alignItems }; + return modelBuilder.flexContainer().withItems(items, itemsStyle).withLayout(flexLayout).withProperties({ CSSStyles: cssStyles || {} }).component(); +} diff --git a/extensions/arc/src/ui/components/radioOptionsGroup.ts b/extensions/arc/src/ui/components/radioOptionsGroup.ts index 2306cd1d18..7846efa9c1 100644 --- a/extensions/arc/src/ui/components/radioOptionsGroup.ts +++ b/extensions/arc/src/ui/components/radioOptionsGroup.ts @@ -5,24 +5,22 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { getErrorMessage } from '../../common/utils'; +import { IReadOnly } from '../dialogs/connectControllerDialog'; export interface RadioOptionsInfo { values?: string[], defaultValue: string } -export class RadioOptionsGroup { +export class RadioOptionsGroup implements IReadOnly { static id: number = 1; private _divContainer!: azdata.DivContainer; private _loadingBuilder: azdata.LoadingComponentBuilder; private _currentRadioOption!: azdata.RadioButtonComponent; - constructor(private _view: azdata.ModelView, private _onNewDisposableCreated: (disposable: vscode.Disposable) => void, private _groupName: string = `RadioOptionsGroup${RadioOptionsGroup.id++}`) { - const divBuilder = this._view.modelBuilder.divContainer(); - const divBuilderWithProperties = divBuilder.withProperties({ clickable: false }); - this._divContainer = divBuilderWithProperties.component(); - const loadingComponentBuilder = this._view.modelBuilder.loadingComponent(); - this._loadingBuilder = loadingComponentBuilder.withItem(this._divContainer); + constructor(private _modelBuilder: azdata.ModelBuilder, private _onNewDisposableCreated: (disposable: vscode.Disposable) => void, private _groupName: string = `RadioOptionsGroup${RadioOptionsGroup.id++}`) { + this._divContainer = this._modelBuilder.divContainer().withProperties({ clickable: false }).component(); + this._loadingBuilder = this._modelBuilder.loadingComponent().withItem(this._divContainer); } public component(): azdata.LoadingComponent { @@ -37,7 +35,7 @@ export class RadioOptionsGroup { const options = optionsInfo.values!; let defaultValue: string = optionsInfo.defaultValue!; options.forEach((option: string) => { - const radioOption = this._view!.modelBuilder.radioButton().withProperties({ + const radioOption = this._modelBuilder.radioButton().withProperties({ label: option, checked: option === defaultValue, name: this._groupName, @@ -60,7 +58,7 @@ export class RadioOptionsGroup { }); } catch (e) { - const errorLabel = this._view!.modelBuilder.text().withProperties({ value: getErrorMessage(e), CSSStyles: { 'color': 'Red' } }).component(); + const errorLabel = this._modelBuilder.text().withProperties({ value: getErrorMessage(e), CSSStyles: { 'color': 'Red' } }).component(); this._divContainer.addItem(errorLabel); } this.component().loading = false; @@ -69,4 +67,21 @@ export class RadioOptionsGroup { get value(): string | undefined { return this._currentRadioOption?.value; } + + get readOnly(): boolean { + return this.enabled; + } + + set readOnly(value: boolean) { + this.enabled = value; + } + + get enabled(): boolean { + return !!this._divContainer.enabled && this._divContainer.items.every(r => r.enabled); + } + + set enabled(value: boolean) { + this._divContainer.items.forEach(r => r.enabled = value); + this._divContainer.enabled = value; + } } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts index 8040730ff6..37c9014d18 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts @@ -76,7 +76,7 @@ export class PostgresComputeAndStoragePage extends DashboardPage { }).component(); const workerNodeslink = this.modelView.modelBuilder.hyperlink().withProperties({ - label: loc.addingWokerNodes, + label: loc.addingWorkerNodes, url: 'https://docs.microsoft.com/azure/azure-arc/data/scale-up-down-postgresql-hyperscale-server-group-using-cli', CSSStyles: { 'margin-block-start': '0px', 'margin-block-end': '0px' } }).component(); diff --git a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts index 2b65375b13..6cd0a27647 100644 --- a/extensions/arc/src/ui/dialogs/connectControllerDialog.ts +++ b/extensions/arc/src/ui/dialogs/connectControllerDialog.ts @@ -14,23 +14,45 @@ import { ControllerModel } from '../../models/controllerModel'; import { InitializingComponent } from '../components/initializingComponent'; import { AzureArcTreeDataProvider } from '../tree/azureArcTreeDataProvider'; import { getErrorMessage } from '../../common/utils'; +import { RadioOptionsGroup } from '../components/radioOptionsGroup'; +import { getCurrentClusterContext, getDefaultKubeConfigPath, getKubeConfigClusterContexts } from '../../common/kubeUtils'; +import { FilePicker } from '../components/filePicker'; export type ConnectToControllerDialogModel = { controllerModel: ControllerModel, password: string }; +export interface IReadOnly { + readOnly?: boolean +} abstract class ControllerDialogBase extends InitializingComponent { + protected _toDispose: vscode.Disposable[] = []; protected modelBuilder!: azdata.ModelBuilder; protected dialog: azdata.window.Dialog; protected urlInputBox!: azdata.InputBoxComponent; + protected kubeConfigInputBox!: FilePicker; + protected clusterContextRadioGroup!: RadioOptionsGroup; protected nameInputBox!: azdata.InputBoxComponent; protected usernameInputBox!: azdata.InputBoxComponent; protected passwordInputBox!: azdata.InputBoxComponent; + protected dispose(): void { + this._toDispose.forEach(disposable => disposable.dispose()); + this._toDispose.length = 0; // clear the _toDispose array + } + protected getComponents(): (azdata.FormComponent & { layout?: azdata.FormItemLayout | undefined; })[] { return [ { component: this.urlInputBox, title: loc.controllerUrl, required: true + }, { + component: this.kubeConfigInputBox.component(), + title: loc.controllerKubeConfig, + required: true + }, { + component: this.clusterContextRadioGroup.component(), + title: loc.controllerClusterContext, + required: true }, { component: this.nameInputBox, title: loc.controllerName, @@ -48,7 +70,7 @@ abstract class ControllerDialogBase extends InitializingComponent { } protected abstract fieldToFocusOn(): azdata.Component; - protected readonlyFields(): azdata.InputBoxComponent[] { return []; } + protected readonlyFields(): IReadOnly[] { return []; } protected initializeFields(controllerInfo: ControllerInfo | undefined, password: string | undefined) { this.urlInputBox = this.modelBuilder.inputBox() @@ -57,6 +79,18 @@ abstract class ControllerDialogBase extends InitializingComponent { // If we have a model then we're editing an existing connection so don't let them modify the URL readOnly: !!controllerInfo }).component(); + this.kubeConfigInputBox = new FilePicker( + this.modelBuilder, + controllerInfo?.kubeConfigFilePath || getDefaultKubeConfigPath(), + (disposable) => this._toDispose.push(disposable) + ); + this.modelBuilder.inputBox() + .withProperties({ + value: controllerInfo?.kubeConfigFilePath || getDefaultKubeConfigPath() + }).component(); + this.clusterContextRadioGroup = new RadioOptionsGroup(this.modelBuilder, (disposable) => this._toDispose.push(disposable)); + this.loadRadioGroup(controllerInfo?.kubeClusterContext); + this._toDispose.push(this.kubeConfigInputBox.onTextChanged(() => this.loadRadioGroup(controllerInfo?.kubeClusterContext))); this.nameInputBox = this.modelBuilder.inputBox() .withProperties({ value: controllerInfo?.name @@ -81,10 +115,20 @@ abstract class ControllerDialogBase extends InitializingComponent { this.dialog = azdata.window.createModelViewDialog(title); } + private loadRadioGroup(previousClusterContext?: string): void { + this.clusterContextRadioGroup.load(async () => { + const clusters = await getKubeConfigClusterContexts(this.kubeConfigInputBox.value!); + return { + values: clusters.map(c => c.name), + defaultValue: getCurrentClusterContext(clusters, previousClusterContext, false), + }; + }); + } + public showDialog(controllerInfo?: ControllerInfo, password: string | undefined = undefined): azdata.window.Dialog { this.id = controllerInfo?.id ?? uuid(); this.resources = controllerInfo?.resources ?? []; - this.dialog.cancelButton.onClick(() => this.handleCancel()); + this._toDispose.push(this.dialog.cancelButton.onClick(() => this.handleCancel())); this.dialog.registerContent(async (view) => { this.modelBuilder = view.modelBuilder; this.initializeFields(controllerInfo, password); @@ -100,7 +144,13 @@ abstract class ControllerDialogBase extends InitializingComponent { this.initialized = true; }); - this.dialog.registerCloseValidator(async () => await this.validate()); + this.dialog.registerCloseValidator(async () => { + const isValidated = await this.validate(); + if (isValidated) { + this.dispose(); + } + return isValidated; + }); this.dialog.okButton.label = loc.connect; this.dialog.cancelButton.label = loc.cancel; azdata.window.openDialog(this.dialog); @@ -116,6 +166,19 @@ abstract class ControllerDialogBase extends InitializingComponent { public waitForClose(): Promise { return this.completionPromise.promise; } + + protected getControllerInfo(url: string, rememberPassword: boolean = false): ControllerInfo { + return { + id: this.id, + url: url, + kubeConfigFilePath: this.kubeConfigInputBox.value!, + kubeClusterContext: this.clusterContextRadioGroup.value!, + name: this.nameInputBox.value ?? '', + username: this.usernameInputBox.value!, + rememberPassword: rememberPassword, + resources: this.resources + }; + } } export class ConnectToControllerDialog extends ControllerDialogBase { @@ -164,14 +227,7 @@ export class ConnectToControllerDialog extends ControllerDialogBase { if (!/.*:\d*$/.test(url)) { url = `${url}:30080`; } - const controllerInfo: ControllerInfo = { - id: this.id, - url: url, - name: this.nameInputBox.value ?? '', - username: this.usernameInputBox.value, - rememberPassword: this.rememberPwCheckBox.checked ?? false, - resources: this.resources - }; + const controllerInfo: ControllerInfo = this.getControllerInfo(url, !!this.rememberPwCheckBox.checked); const controllerModel = new ControllerModel(this.treeDataProvider, controllerInfo, this.passwordInputBox.value); try { // Validate that we can connect to the controller, this also populates the controllerRegistration from the connection response. @@ -202,6 +258,8 @@ export class PasswordToControllerDialog extends ControllerDialogBase { protected readonlyFields() { return [ this.urlInputBox, + this.kubeConfigInputBox, + this.clusterContextRadioGroup, this.nameInputBox, this.usernameInputBox ]; @@ -229,14 +287,7 @@ export class PasswordToControllerDialog extends ControllerDialogBase { return false; } } - const controllerInfo: ControllerInfo = { - id: this.id, - url: this.urlInputBox.value!, - name: this.nameInputBox.value!, - username: this.usernameInputBox.value!, - rememberPassword: false, - resources: [] - }; + const controllerInfo: ControllerInfo = this.getControllerInfo(this.urlInputBox.value!, false); const controllerModel = new ControllerModel(this.treeDataProvider, controllerInfo, this.passwordInputBox.value); this.completionPromise.resolve({ controllerModel: controllerModel, password: this.passwordInputBox.value }); return true; diff --git a/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts b/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts index 35c7a85a8e..febf776f3e 100644 --- a/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts +++ b/extensions/arc/src/ui/tree/azureArcTreeDataProvider.ts @@ -10,7 +10,7 @@ import { ControllerModel } from '../../models/controllerModel'; import { ControllerTreeNode } from './controllerTreeNode'; import { TreeNode } from './treeNode'; -const mementoToken = 'arcControllers'; +const mementoToken = 'arcDataControllers'; /** * The TreeDataProvider for the Azure Arc view, which displays a list of registered diff --git a/extensions/machine-learning/src/test/views/utils.ts b/extensions/machine-learning/src/test/views/utils.ts index 42539b8dd7..4656d9c68a 100644 --- a/extensions/machine-learning/src/test/views/utils.ts +++ b/extensions/machine-learning/src/test/views/utils.ts @@ -86,7 +86,7 @@ export function createViewContext(): ViewTestContext { withProps: () => checkBoxBuilder, withValidation: () => checkBoxBuilder }; - let inputBox: () => azdata.InputBoxComponent = () => Object.assign({}, componentBase, { + let inputBox: () => azdata.InputBoxComponent = () => Object.assign(Object.assign({}, componentBase), { onTextChanged: onClick.event!, onEnterKeyPressed: undefined!, value: ''