diff --git a/.vscode/launch.json b/.vscode/launch.json index 27d8205ec0..38b5245f1a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -72,6 +72,7 @@ "type": "chrome", "request": "attach", "name": "Attach to azuredatastudio", + "timeout": 50000, "port": 9222 }, { diff --git a/extensions/resource-deployment/package.json b/extensions/resource-deployment/package.json index 9728558a57..2018c215a4 100644 --- a/extensions/resource-deployment/package.json +++ b/extensions/resource-deployment/package.json @@ -237,7 +237,7 @@ ], "providers": [ { - "wizard": { + "bdcWizard": { "type": "new-aks", "notebook": "%bdc-2019-aks-notebook%" }, @@ -257,7 +257,7 @@ "when": "target=new-aks&&version=bdc2019" }, { - "wizard": { + "bdcWizard": { "type": "existing-aks", "notebook": "%bdc-2019-existing-aks-notebook%" }, @@ -272,7 +272,7 @@ "when": "target=existing-aks&&version=bdc2019" }, { - "wizard": { + "bdcWizard": { "type": "existing-kubeadm", "notebook": "%bdc-2019-existing-kubeadm-notebook%" }, diff --git a/extensions/resource-deployment/src/constants.ts b/extensions/resource-deployment/src/constants.ts index 196d1691b5..f6d33ae592 100644 --- a/extensions/resource-deployment/src/constants.ts +++ b/extensions/resource-deployment/src/constants.ts @@ -5,3 +5,4 @@ export const DeploymentConfigurationKey: string = 'deployment'; export const AzdataInstallLocationKey: string = 'azdataInstallLocation'; +export const ToolsInstallPath = 'AZDATA_NB_VAR_TOOLS_INSTALLATION_PATH'; diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index 5f8b7957aa..c8e712f4d4 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -2,6 +2,7 @@ * 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'; @@ -40,8 +41,12 @@ export interface DialogDeploymentProvider extends DeploymentProviderBase { dialog: DialogInfo; } -export interface WizardDeploymentProvider extends DeploymentProviderBase { - wizard: WizardInfo; +export interface BdcWizardDeploymentProvider extends DeploymentProviderBase { + bdcWizard: WizardInfo; +} + +export interface NotebookWizardDeploymentProvider extends DeploymentProviderBase { + notebookWizard: NotebookWizardInfo; } export interface NotebookDeploymentProvider extends DeploymentProviderBase { @@ -64,8 +69,12 @@ export function instanceOfDialogDeploymentProvider(obj: any): obj is DialogDeplo return obj && 'dialog' in obj; } -export function instanceOfWizardDeploymentProvider(obj: any): obj is WizardDeploymentProvider { - return obj && 'wizard' in obj; +export function instanceOfWizardDeploymentProvider(obj: any): obj is BdcWizardDeploymentProvider { + return obj && 'bdcWizard' in obj; +} + +export function instanceOfNotebookWizardDeploymentProvider(obj: any): obj is NotebookWizardDeploymentProvider { + return obj && 'notebookWizard' in obj; } export function instanceOfNotebookDeploymentProvider(obj: any): obj is NotebookDeploymentProvider { @@ -89,13 +98,31 @@ export interface DeploymentProviderBase { when: string; } -export type DeploymentProvider = DialogDeploymentProvider | WizardDeploymentProvider | NotebookDeploymentProvider | WebPageDeploymentProvider | DownloadDeploymentProvider | CommandDeploymentProvider; +export type DeploymentProvider = DialogDeploymentProvider | BdcWizardDeploymentProvider | NotebookWizardDeploymentProvider | NotebookDeploymentProvider | WebPageDeploymentProvider | DownloadDeploymentProvider | CommandDeploymentProvider; export interface WizardInfo { notebook: string | NotebookInfo; type: BdcDeploymentType; } +export interface NotebookWizardInfo extends WizardInfoBase { + notebook: string | NotebookInfo; +} + +export interface WizardInfoBase extends SharedFieldAttributes { + taskName?: string; + type?: DeploymentType; + runNotebook?: boolean; + actionText?: string; + title: string; + pages: NotebookWizardPageInfo[]; + summaryPage: NotebookWizardPageInfo; + generateSummaryPage: boolean; +} + +export interface NotebookWizardPageInfo extends PageInfoBase { + description?: string; +} export interface NotebookBasedDialogInfo extends DialogInfoBase { notebook: string | NotebookInfo; runNotebook?: boolean; @@ -123,20 +150,24 @@ export interface DialogInfoBase { actionText?: string; } -export interface DialogTabInfo { - title: string; - sections: SectionInfo[]; - labelWidth?: string; - inputWidth?: string; +export interface DialogTabInfo extends PageInfoBase { } -export interface SectionInfo { +export interface PageInfoBase extends SharedFieldAttributes { title: string; - fields?: FieldInfo[]; // Use this if the dialog is not wide. All fields will be displayed in one column, label will be placed on top of the input component. - rows?: RowInfo[]; // Use this for wide dialog or wizard. label will be placed to the left of the input component. + isSummaryPage?: boolean; + sections: SectionInfo[]; +} + +export interface SharedFieldAttributes { labelWidth?: string; inputWidth?: string; labelPosition?: LabelPosition; // Default value is top +} +export interface SectionInfo extends SharedFieldAttributes { + title?: string; + fields?: FieldInfo[]; // Use this if the dialog is not wide. All fields will be displayed in one column, label will be placed on top of the input component. + rows?: RowInfo[]; // Use this for wide dialog or wizard. label will be placed to the left of the input component. collapsible?: boolean; collapsed?: boolean; spaceBetweenFields?: string; @@ -146,9 +177,13 @@ export interface RowInfo { fields: FieldInfo[]; } -export interface FieldInfo { +export interface SubFieldInfo { label: string; variableName?: string; +} + +export interface FieldInfo extends SubFieldInfo, SharedFieldAttributes { + subFields?: SubFieldInfo[]; type: FieldType; defaultValue?: string; confirmationRequired?: boolean; @@ -162,21 +197,26 @@ export interface FieldInfo { options?: string[] | azdata.CategoryValue[]; placeHolder?: string; userName?: string; // needed for sql server's password complexity requirement check, password can not include the login name. - labelWidth?: string; - inputWidth?: string; description?: string; - labelPosition?: LabelPosition; // overwrite the labelPosition of SectionInfo. fontStyle?: FontStyle; labelFontWeight?: FontWeight; + textFontWeight?: FontWeight; links?: azdata.LinkArea[]; - editable?: boolean; // for editable dropdown, + editable?: boolean; // for editable drop-down, enabled?: boolean; } -export interface AzureAccountFieldInfo extends FieldInfo { +export interface KubeClusterContextFieldInfo extends FieldInfo { + configFileVariableName?: string; +} +export interface AzureAccountFieldInfo extends AzureLocationsFieldInfo { subscriptionVariableName?: string; resourceGroupVariableName?: string; +} + +export interface AzureLocationsFieldInfo extends FieldInfo { locationVariableName?: string; + displayLocationVariableName?: string; locations?: string[] } @@ -202,9 +242,13 @@ export enum FieldType { SQLPassword = 'sql_password', Password = 'password', Options = 'options', + RadioOptions = 'radio_options', ReadonlyText = 'readonly_text', Checkbox = 'checkbox', - AzureAccount = 'azure_account' + AzureAccount = 'azure_account', + AzureLocations = 'azure_locations', + FilePicker = 'file_picker', + KubeClusterContextPicker = 'kube_cluster_context_picker' } export interface NotebookInfo { @@ -278,6 +322,12 @@ export const enum BdcDeploymentType { ExistingKubeAdm = 'existing-kubeadm' } +export const enum ArcDeploymentType { + NewControlPlane = 'new-control-plane' +} + +export type DeploymentType = ArcDeploymentType | BdcDeploymentType; + export interface Command { command: string; sudo?: boolean; diff --git a/extensions/resource-deployment/src/localizedConstants.ts b/extensions/resource-deployment/src/localizedConstants.ts index cafabee750..c047e6850c 100644 --- a/extensions/resource-deployment/src/localizedConstants.ts +++ b/extensions/resource-deployment/src/localizedConstants.ts @@ -11,3 +11,7 @@ export const account = localize('azure.account', "Azure Account"); export const subscription = localize('azure.account.subscription', "Subscription"); export const resourceGroup = localize('azure.account.resourceGroup', "Resource Group"); export const location = localize('azure.account.location', "Azure Location"); +export const browse = localize('filePicker.browse', "Browse"); +export const select = localize('filePicker.select', "Select"); +export const kubeConfigFilePath = localize('kubeConfigClusterPicker.kubeConfigFilePatht', "Kube config file path"); +export const clusterContextNotFound = localize('kubeConfigClusterPicker.clusterContextNotFound', "No cluster context information found"); diff --git a/extensions/resource-deployment/src/services/kubeService.ts b/extensions/resource-deployment/src/services/kubeService.ts index a728ffe937..85bf8b57a1 100644 --- a/extensions/resource-deployment/src/services/kubeService.ts +++ b/extensions/resource-deployment/src/services/kubeService.ts @@ -14,39 +14,49 @@ export interface KubeClusterContext { } export interface IKubeService { - getDefautConfigPath(): string; + getDefaultConfigPath(): string; getClusterContexts(configFile: string): Promise; } export class KubeService implements IKubeService { - getDefautConfigPath(): string { - return path.join(os.homedir(), '.kube', 'config'); + getDefaultConfigPath(): string { + return getDefaultKubeConfigPath(); } getClusterContexts(configFile: string): Promise { - return fs.promises.access(configFile).catch((error) => { - if (error && error.code === 'ENOENT') { - return []; - } else { - throw error; - } - }).then(() => { - const config = yamljs.load(configFile); - const rawContexts = config['contexts']; - const currentContext = config['current-context']; - const contexts: KubeClusterContext[] = []; - if (currentContext && rawContexts && rawContexts.length > 0) { - rawContexts.forEach(rawContext => { - const name = rawContext['name']; - if (name) { - contexts.push({ - name: name, - isCurrentContext: name === currentContext - }); - } - }); - } - return contexts; - }); + return getKubeConfigClusterContexts(configFile); } } + +export function getKubeConfigClusterContexts(configFile: string): Promise { + return fs.promises.access(configFile).catch((error) => { + if (error && error.code === 'ENOENT') { + return []; + } + else { + throw error; + } + }).then(() => { + const config = yamljs.load(configFile); + const rawContexts = config['contexts']; + const currentContext = config['current-context']; + const contexts: KubeClusterContext[] = []; + if (currentContext && rawContexts && rawContexts.length > 0) { + rawContexts.forEach(rawContext => { + const name = rawContext['name']; + if (name) { + contexts.push({ + name: name, + isCurrentContext: name === currentContext + }); + } + }); + } + return contexts; + }); +} + +export function getDefaultKubeConfigPath(): string { + return path.join(os.homedir(), '.kube', 'config'); +} + diff --git a/extensions/resource-deployment/src/services/notebookService.ts b/extensions/resource-deployment/src/services/notebookService.ts index e76e51b219..9935e5df28 100644 --- a/extensions/resource-deployment/src/services/notebookService.ts +++ b/extensions/resource-deployment/src/services/notebookService.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; +import { EOL } from 'os'; import * as path from 'path'; import { isString } from 'util'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; -import { IPlatformService } from './platformService'; import { NotebookInfo } from '../interfaces'; -import { getErrorMessage, getDateTimeString } from '../utils'; +import { getDateTimeString, getErrorMessage } from '../utils'; +import { IPlatformService } from './platformService'; const localize = nls.loadMessageBundle(); export interface Notebook { @@ -36,6 +37,7 @@ export interface INotebookService { launchNotebookWithContent(title: string, content: string): Thenable; getNotebook(notebook: string | NotebookInfo): Promise; executeNotebook(notebook: any, env?: NodeJS.ProcessEnv): Promise; + backgroundExecuteNotebook(taskName: string | undefined, notebookInfo: string | NotebookInfo, tempNoteBookPrefix: string, platformService: IPlatformService): void; } export class NotebookService implements INotebookService { @@ -107,6 +109,43 @@ export class NotebookService implements INotebookService { } } + public backgroundExecuteNotebook(taskName: string | undefined, notebookInfo: string | NotebookInfo, tempNotebookPrefix: string, platformService: IPlatformService): void { + azdata.tasks.startBackgroundOperation({ + displayName: taskName!, + description: taskName!, + isCancelable: false, + operation: async op => { + op.updateStatus(azdata.TaskStatus.InProgress); + const notebook = await this.getNotebook(notebookInfo); + const result = await this.executeNotebook(notebook); + if (result.succeeded) { + op.updateStatus(azdata.TaskStatus.Succeeded); + } else { + op.updateStatus(azdata.TaskStatus.Failed, result.errorMessage); + if (result.outputNotebook) { + const viewErrorDetail = localize('resourceDeployment.ViewErrorDetail', "View error detail"); + const taskFailedMessage = localize('resourceDeployment.BackgroundExecutionFailed', "The task \"{0}\" has failed.", taskName); + const selectedOption = await vscode.window.showErrorMessage(taskFailedMessage, viewErrorDetail); + platformService.logToOutputChannel(taskFailedMessage); + if (selectedOption === viewErrorDetail) { + try { + this.launchNotebookWithContent(`${tempNotebookPrefix}-${getDateTimeString()}`, result.outputNotebook); + } catch (error) { + const launchNotebookError = localize('resourceDeployment.FailedToOpenNotebook', "An error occurred launching the output notebook. {1}{2}.", EOL, getErrorMessage(error)); + platformService.logToOutputChannel(launchNotebookError); + vscode.window.showErrorMessage(launchNotebookError); + } + } + } else { + const errorMessage = localize('resourceDeployment.TaskFailedWithNoOutputNotebook', "The task \"{0}\" failed and no output Notebook was generated.", taskName); + platformService.logToOutputChannel(errorMessage); + vscode.window.showErrorMessage(errorMessage); + } + } + } + }); + } + async getNotebookFullPath(notebook: string | NotebookInfo): Promise { const notebookPath = this.getNotebookPath(notebook); let notebookExists = await this.platformService.fileExists(notebookPath); diff --git a/extensions/resource-deployment/src/services/resourceTypeService.ts b/extensions/resource-deployment/src/services/resourceTypeService.ts index 9790db8be9..7b8ec0615e 100644 --- a/extensions/resource-deployment/src/services/resourceTypeService.ts +++ b/extensions/resource-deployment/src/services/resourceTypeService.ts @@ -13,11 +13,13 @@ import * as nls from 'vscode-nls'; import { INotebookService } from './notebookService'; import { IPlatformService } from './platformService'; import { IToolsService } from './toolsService'; -import { ResourceType, ResourceTypeOption, NotebookInfo, DeploymentProvider, instanceOfWizardDeploymentProvider, instanceOfDialogDeploymentProvider, instanceOfNotebookDeploymentProvider, instanceOfDownloadDeploymentProvider, instanceOfWebPageDeploymentProvider, instanceOfCommandDeploymentProvider, instanceOfNotebookBasedDialogInfo } from '../interfaces'; +import { ResourceType, ResourceTypeOption, NotebookInfo, DeploymentProvider, instanceOfWizardDeploymentProvider, instanceOfDialogDeploymentProvider, instanceOfNotebookDeploymentProvider, instanceOfDownloadDeploymentProvider, instanceOfWebPageDeploymentProvider, instanceOfCommandDeploymentProvider, instanceOfNotebookBasedDialogInfo, instanceOfNotebookWizardDeploymentProvider } from '../interfaces'; import { DeployClusterWizard } from '../ui/deployClusterWizard/deployClusterWizard'; import { DeploymentInputDialog } from '../ui/deploymentInputDialog'; + import { KubeService } from './kubeService'; import { AzdataService } from './azdataService'; +import { NotebookWizard } from '../ui/notebookWizard/notebookWizard'; const localize = nls.loadMessageBundle(); export interface IResourceTypeService { @@ -66,8 +68,11 @@ export class ResourceTypeService implements IResourceTypeService { } else if (instanceOfDialogDeploymentProvider(provider) && instanceOfNotebookBasedDialogInfo(provider.dialog)) { this.updateNotebookPath(provider.dialog, extensionPath); } - else if ('wizard' in provider) { - this.updateNotebookPath(provider.wizard, extensionPath); + else if ('bdcWizard' in provider) { + this.updateNotebookPath(provider.bdcWizard, extensionPath); + } + else if ('notebookWizard' in provider) { + this.updateNotebookPath(provider.notebookWizard, extensionPath); } }); } @@ -168,6 +173,7 @@ export class ResourceTypeService implements IResourceTypeService { resourceType.providers.forEach(provider => { const providerPositionInfo = `${positionInfo}, provider index: ${providerIndex} `; if (!instanceOfWizardDeploymentProvider(provider) + && !instanceOfNotebookWizardDeploymentProvider(provider) && !instanceOfDialogDeploymentProvider(provider) && !instanceOfNotebookDeploymentProvider(provider) && !instanceOfDownloadDeploymentProvider(provider) @@ -203,24 +209,27 @@ export class ResourceTypeService implements IResourceTypeService { private getProvider(resourceType: ResourceType, selectedOptions: { option: string, value: string }[]): DeploymentProvider | undefined { for (let i = 0; i < resourceType.providers.length; i++) { const provider = resourceType.providers[i]; + if (provider.when === undefined || provider.when.toString().toLowerCase() === 'true') { + return provider; + } else { + const expected = provider.when.replace(' ', '').split('&&').sort(); + let actual: string[] = []; + selectedOptions.forEach(option => { + actual.push(`${option.option}=${option.value}`); + }); + actual = actual.sort(); - const expected = provider.when.replace(' ', '').split('&&').sort(); - let actual: string[] = []; - selectedOptions.forEach(option => { - actual.push(`${option.option}=${option.value}`); - }); - actual = actual.sort(); - - if (actual.length === expected.length) { - let matches = true; - for (let j = 0; j < actual.length; j++) { - if (actual[j] !== expected[j]) { - matches = false; - break; + if (actual.length === expected.length) { + let matches = true; + for (let j = 0; j < actual.length; j++) { + if (actual[j] !== expected[j]) { + matches = false; + break; + } + } + if (matches) { + return provider; } - } - if (matches) { - return provider; } } } @@ -230,7 +239,10 @@ export class ResourceTypeService implements IResourceTypeService { public startDeployment(provider: DeploymentProvider): void { const self = this; if (instanceOfWizardDeploymentProvider(provider)) { - const wizard = new DeployClusterWizard(provider.wizard, new KubeService(), new AzdataService(this.platformService), this.notebookService); + const wizard = new DeployClusterWizard(provider.bdcWizard, new KubeService(), new AzdataService(this.platformService), this.notebookService); + wizard.open(); + } else if (instanceOfNotebookWizardDeploymentProvider(provider)) { + const wizard = new NotebookWizard(provider.notebookWizard, this.notebookService, this.platformService); wizard.open(); } else if (instanceOfDialogDeploymentProvider(provider)) { const dialog = new DeploymentInputDialog(this.notebookService, this.platformService, provider.dialog); diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/constants.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/constants.ts index b087c7aefa..b759766b04 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/constants.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/constants.ts @@ -69,4 +69,3 @@ export const DockerRegistry_VariableName = 'AZDATA_NB_VAR_BDC_REGISTRY'; export const DockerImageTag_VariableName = 'AZDATA_NB_VAR_BDC_DOCKER_IMAGE_TAG'; export const DockerUsername_VariableName = 'AZDATA_NB_VAR_BDC_DOCKER_USERNAME'; export const DockerPassword_VariableName = 'AZDATA_NB_VAR_BDC_DOCKER_PASSWORD'; -export const ToolsInstallPath = 'AZDATA_NB_VAR_TOOLS_INSTALLATION_PATH'; diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/deployClusterWizardModel.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/deployClusterWizardModel.ts index c7d0a1a644..00cf8625e9 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/deployClusterWizardModel.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/deployClusterWizardModel.ts @@ -10,7 +10,7 @@ import { KubeCtlToolName } from '../../services/tools/kubeCtlTool'; import { getRuntimeBinaryPathEnvironmentVariableName } from '../../utils'; import { Model } from '../model'; import * as VariableNames from './constants'; - +import { ToolsInstallPath } from './../../constants'; export class DeployClusterWizardModel extends Model { constructor(public deploymentTarget: BdcDeploymentType) { @@ -167,7 +167,7 @@ export class DeployClusterWizardModel extends Model { } const kubeCtlEnvVarName: string = getRuntimeBinaryPathEnvironmentVariableName(KubeCtlToolName); statements.push(`os.environ["${kubeCtlEnvVarName}"] = "${this.escapeForNotebookCodeCell(process.env[kubeCtlEnvVarName]!)}"`); - statements.push(`os.environ["PATH"] = os.environ["PATH"] + "${delimiter}" + "${this.escapeForNotebookCodeCell(process.env[VariableNames.ToolsInstallPath]!)}"`); + statements.push(`os.environ["PATH"] = os.environ["PATH"] + "${delimiter}" + "${this.escapeForNotebookCodeCell(process.env[ToolsInstallPath]!)}"`); statements.push(`print('Variables have been set successfully.')`); return statements.map(line => line + EOL); } diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts index d5cf9fe291..c912134e26 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/azureSettingsPage.ts @@ -6,12 +6,13 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; -import { DeployClusterWizard } from '../deployClusterWizard'; -import { SectionInfo, FieldType, LabelPosition } from '../../../interfaces'; +import { FieldType, LabelPosition, SectionInfo } from '../../../interfaces'; +import { createSection, getDropdownComponent, InputComponents, InputComponent, setModelValues, Validator } from '../../modelViewUtils'; import { WizardPageBase } from '../../wizardPageBase'; -import { createSection, InputComponents, setModelValues, Validator, getDropdownComponent, MissingRequiredInformationErrorMessage } from '../../modelViewUtils'; -import { SubscriptionId_VariableName, ResourceGroup_VariableName, Location_VariableName, AksName_VariableName, VMCount_VariableName, VMSize_VariableName } from '../constants'; +import { AksName_VariableName, Location_VariableName, ResourceGroup_VariableName, SubscriptionId_VariableName, VMCount_VariableName, VMSize_VariableName } from '../constants'; +import { DeployClusterWizard } from '../deployClusterWizard'; const localize = nls.loadMessageBundle(); +const MissingRequiredInformationErrorMessage = localize('deployCluster.MissingRequiredInfoError', "Please fill out the required fields marked with red asterisks."); export class AzureSettingsPage extends WizardPageBase { private inputComponents: InputComponents = {}; @@ -136,7 +137,7 @@ export class AzureSettingsPage extends WizardPageBase { onNewDisposableCreated: (disposable: vscode.Disposable): void => { self.wizard.registerDisposable(disposable); }, - onNewInputComponentCreated: (name: string, component: azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent): void => { + onNewInputComponentCreated: (name: string, component: InputComponent): void => { self.inputComponents[name] = { component: component }; }, onNewValidatorCreated: (validator: Validator): void => { diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts index 60466fae2d..7c404095b9 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { DeployClusterWizard } from '../deployClusterWizard'; import { SectionInfo, FieldType, LabelPosition } from '../../../interfaces'; -import { createSection, InputComponents, setModelValues, Validator, getInputBoxComponent, isValidSQLPassword, getInvalidSQLPasswordMessage, getPasswordMismatchMessage } from '../../modelViewUtils'; +import { createSection, InputComponents, setModelValues, Validator, getInputBoxComponent, isValidSQLPassword, getInvalidSQLPasswordMessage, getPasswordMismatchMessage, InputComponent } from '../../modelViewUtils'; import { WizardPageBase } from '../../wizardPageBase'; import * as VariableNames from '../constants'; import { EOL } from 'os'; @@ -197,7 +197,7 @@ export class ClusterSettingsPage extends WizardPageBase { onNewDisposableCreated: (disposable: vscode.Disposable): void => { self.wizard.registerDisposable(disposable); }, - onNewInputComponentCreated: (name: string, component: azdata.DropDownComponent | azdata.InputBoxComponent | azdata.CheckBoxComponent): void => { + onNewInputComponentCreated: (name: string, component: InputComponent): void => { self.inputComponents[name] = { component: component }; }, onNewValidatorCreated: (validator: Validator): void => { @@ -211,7 +211,7 @@ export class ClusterSettingsPage extends WizardPageBase { onNewDisposableCreated: (disposable: vscode.Disposable): void => { self.wizard.registerDisposable(disposable); }, - onNewInputComponentCreated: (name: string, component: azdata.DropDownComponent | azdata.InputBoxComponent | azdata.CheckBoxComponent): void => { + onNewInputComponentCreated: (name: string, component: InputComponent): void => { self.inputComponents[name] = { component: component }; }, onNewValidatorCreated: (validator: Validator): void => { @@ -225,7 +225,7 @@ export class ClusterSettingsPage extends WizardPageBase { onNewDisposableCreated: (disposable: vscode.Disposable): void => { self.wizard.registerDisposable(disposable); }, - onNewInputComponentCreated: (name: string, component: azdata.DropDownComponent | azdata.InputBoxComponent | azdata.CheckBoxComponent): void => { + onNewInputComponentCreated: (name: string, component: InputComponent): void => { self.inputComponents[name] = { component: component }; }, onNewValidatorCreated: (validator: Validator): void => { diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts index 84f47a837a..476f74b31b 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { DeployClusterWizard } from '../deployClusterWizard'; import { SectionInfo, FieldType } from '../../../interfaces'; -import { Validator, InputComponents, createSection, createGroupContainer, createLabel, createFlexContainer, createTextInput, createNumberInput, setModelValues, getInputBoxComponent, getCheckboxComponent, getDropdownComponent } from '../../modelViewUtils'; +import { Validator, InputComponents, InputComponent, createSection, createGroupContainer, createLabel, createFlexContainer, createTextInput, createNumberInput, setModelValues, getInputBoxComponent, getCheckboxComponent, getDropdownComponent } from '../../modelViewUtils'; import { WizardPageBase } from '../../wizardPageBase'; import * as VariableNames from '../constants'; import { AuthenticationMode } from '../deployClusterWizardModel'; @@ -123,7 +123,7 @@ export class ServiceSettingsPage extends WizardPageBase { onNewDisposableCreated: (disposable: vscode.Disposable): void => { this.wizard.registerDisposable(disposable); }, - onNewInputComponentCreated: (name: string, component: azdata.DropDownComponent | azdata.InputBoxComponent | azdata.CheckBoxComponent): void => { + onNewInputComponentCreated: (name: string, component: InputComponent): void => { this.inputComponents[name] = { component: component }; }, onNewValidatorCreated: (validator: Validator): void => { diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts index 5a2b92444f..58b40d12e8 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/targetClusterPage.ts @@ -53,7 +53,7 @@ export class TargetClusterContextPage extends WizardPageBase { this._toDispose.push(disposable); }, - onNewInputComponentCreated: (name: string, component: azdata.DropDownComponent | azdata.InputBoxComponent | azdata.CheckBoxComponent, inputValueTransformer?: InputValueTransformer): void => { + onNewInputComponentCreated: (name: string, component: InputComponent, inputValueTransformer?: InputValueTransformer): void => { this.inputComponents[name] = { component: component, inputValueTransformer: inputValueTransformer }; }, onNewValidatorCreated: (validator: Validator): void => { @@ -88,39 +87,6 @@ export class DeploymentInputDialog extends DialogBase { } private executeNotebook(notebookDialogInfo: NotebookBasedDialogInfo): void { - azdata.tasks.startBackgroundOperation({ - displayName: notebookDialogInfo.taskName!, - description: notebookDialogInfo.taskName!, - isCancelable: false, - operation: async op => { - op.updateStatus(azdata.TaskStatus.InProgress); - const notebook = await this.notebookService.getNotebook(notebookDialogInfo.notebook); - const result = await this.notebookService.executeNotebook(notebook); - if (result.succeeded) { - op.updateStatus(azdata.TaskStatus.Succeeded); - } else { - op.updateStatus(azdata.TaskStatus.Failed, result.errorMessage); - if (result.outputNotebook) { - const viewErrorDetail = localize('resourceDeployment.ViewErrorDetail', "View error detail"); - const taskFailedMessage = localize('resourceDeployment.DeployFailed', "The task \"{0}\" has failed.", notebookDialogInfo.taskName); - const selectedOption = await vscode.window.showErrorMessage(taskFailedMessage, viewErrorDetail); - this.platformService.logToOutputChannel(taskFailedMessage); - if (selectedOption === viewErrorDetail) { - try { - this.notebookService.launchNotebookWithContent(`deploy-${getDateTimeString()}`, result.outputNotebook); - } catch (error) { - const launchNotebookError = localize('resourceDeployment.FailedToOpenNotebook', "An error occurred launching the output notebook. {1}{2}.", EOL, getErrorMessage(error)); - this.platformService.logToOutputChannel(launchNotebookError); - vscode.window.showErrorMessage(launchNotebookError); - } - } - } else { - const errorMessage = localize('resourceDeployment.TaskFailedWithNoOutputNotebook', "The task \"{0}\" failed and no output Notebook was generated.", notebookDialogInfo.taskName); - this.platformService.logToOutputChannel(errorMessage); - vscode.window.showErrorMessage(errorMessage); - } - } - } - }); + this.notebookService.backgroundExecuteNotebook(notebookDialogInfo.taskName, notebookDialogInfo.notebook, 'deploy', this.platformService); } } diff --git a/extensions/resource-deployment/src/ui/modelViewUtils.ts b/extensions/resource-deployment/src/ui/modelViewUtils.ts index cf4d6fe3de..a3eb0296cd 100644 --- a/extensions/resource-deployment/src/ui/modelViewUtils.ts +++ b/extensions/resource-deployment/src/ui/modelViewUtils.ts @@ -2,23 +2,26 @@ * 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 { EOL, homedir as os_homedir } from 'os'; +import * as path from 'path'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; -import { DialogInfoBase, FieldType, FieldInfo, SectionInfo, LabelPosition, FontWeight, FontStyle, AzureAccountFieldInfo } from '../interfaces'; -import { Model } from './model'; -import { getDateTimeString } from '../utils'; -import { azureResource } from '../../../azurecore/src/azureResource/azure-resource'; import * as azurecore from '../../../azurecore/src/azurecore'; +import { azureResource } from '../../../azurecore/src/azureResource/azure-resource'; +import { AzureAccountFieldInfo, AzureLocationsFieldInfo, DialogInfoBase, FieldInfo, FieldType, FontStyle, FontWeight, LabelPosition, PageInfoBase, SectionInfo, KubeClusterContextFieldInfo } from '../interfaces'; import * as loc from '../localizedConstants'; -import { EOL } from 'os'; +import { getDefaultKubeConfigPath, getKubeConfigClusterContexts } from '../services/kubeService'; +import { getDateTimeString, getErrorMessage } from '../utils'; +import { WizardInfoBase } from './../interfaces'; +import { Model } from './model'; const localize = nls.loadMessageBundle(); export type Validator = () => { valid: boolean, message: string }; export type InputValueTransformer = (inputValue: string) => string; -export type InputComponents = { [s: string]: { component: azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent; inputValueTransformer?: InputValueTransformer } }; +export type InputComponent = azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent | azdata.RadioButtonComponent; +export type InputComponents = { [s: string]: { component: InputComponent; inputValueTransformer?: InputValueTransformer } }; export function getInputBoxComponent(name: string, inputComponents: InputComponents): azdata.InputBoxComponent { return inputComponents[name].component; @@ -32,6 +35,10 @@ export function getCheckboxComponent(name: string, inputComponents: InputCompone return inputComponents[name].component; } +export function getTextComponent(name: string, inputComponents: InputComponents): azdata.TextComponent { + return inputComponents[name].component; +} + export const DefaultInputComponentWidth = '400px'; export const DefaultLabelComponentWidth = '200px'; @@ -41,7 +48,8 @@ export interface DialogContext extends CreateContext { } export interface WizardPageContext extends CreateContext { - sections: SectionInfo[]; + wizardInfo: WizardInfoBase; + pageInfo: PageInfoBase; page: azdata.window.WizardPage; container: azdata.window.Wizard; } @@ -57,6 +65,24 @@ interface FieldContext extends CreateContext { view: azdata.ModelView; } +interface FilePickerInputs { + input: azdata.InputBoxComponent; + browseButton: azdata.ButtonComponent; +} + +interface RadioOptionsInputs { + optionsList: azdata.DivContainer; + loader: azdata.LoadingComponent; +} + +interface KubeClusterContextFieldContext extends FieldContext { + fieldInfo: KubeClusterContextFieldInfo; +} + +interface AzureLocationsFieldContext extends FieldContext { + fieldInfo: AzureLocationsFieldInfo; +} + interface AzureAccountFieldContext extends FieldContext { fieldInfo: AzureAccountFieldInfo; } @@ -65,7 +91,7 @@ interface CreateContext { container: azdata.window.Dialog | azdata.window.Wizard; onNewValidatorCreated: (validator: Validator) => void; onNewDisposableCreated: (disposable: vscode.Disposable) => void; - onNewInputComponentCreated: (name: string, component: azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent, inputValueTransformer?: InputValueTransformer) => void; + onNewInputComponentCreated: (name: string, component: InputComponent, inputValueTransformer?: InputValueTransformer) => void; } export function createTextInput(view: azdata.ModelView, inputInfo: { defaultValue?: string, ariaLabel: string, required?: boolean, placeHolder?: string, width?: string, enabled?: boolean }): azdata.InputBoxComponent { @@ -133,6 +159,7 @@ export function initializeDialog(dialogContext: DialogContext): void { const sections = tabInfo.sections.map(sectionInfo => { sectionInfo.inputWidth = sectionInfo.inputWidth || tabInfo.inputWidth || DefaultInputComponentWidth; sectionInfo.labelWidth = sectionInfo.labelWidth || tabInfo.labelWidth || DefaultLabelComponentWidth; + sectionInfo.labelPosition = sectionInfo.labelPosition || tabInfo.labelPosition; return createSection({ sectionInfo: sectionInfo, view: view, @@ -161,9 +188,10 @@ export function initializeDialog(dialogContext: DialogContext): void { export function initializeWizardPage(context: WizardPageContext): void { context.page.registerContent((view: azdata.ModelView) => { - const sections = context.sections.map(sectionInfo => { - sectionInfo.inputWidth = sectionInfo.inputWidth || DefaultInputComponentWidth; - sectionInfo.labelWidth = sectionInfo.labelWidth || DefaultLabelComponentWidth; + const sections = context.pageInfo.sections.map(sectionInfo => { + sectionInfo.inputWidth = sectionInfo.inputWidth || context.pageInfo.inputWidth || context.wizardInfo.inputWidth || DefaultInputComponentWidth; + sectionInfo.labelWidth = sectionInfo.labelWidth || context.pageInfo.labelWidth || context.wizardInfo.labelWidth || DefaultLabelComponentWidth; + sectionInfo.labelPosition = sectionInfo.labelPosition || context.pageInfo.labelPosition || context.wizardInfo.labelPosition; return createSection({ view: view, container: context.container, @@ -180,7 +208,7 @@ export function initializeWizardPage(context: WizardPageContext): void { componentWidth: '100%' } ); - const form = formBuilder.withLayout({ width: '100%' }).component(); + const form: azdata.FormContainer = formBuilder.withLayout({ width: '100%' }).component(); return view.initializeModel(form); }); } @@ -239,12 +267,16 @@ export function createGroupContainer(view: azdata.ModelView, items: azdata.Compo return view.modelBuilder.groupContainer().withItems(items).withLayout(layout).component(); } -function addLabelInputPairToContainer(view: azdata.ModelView, components: azdata.Component[], label: azdata.Component, input: azdata.Component, labelPosition?: LabelPosition) { +function addLabelInputPairToContainer(view: azdata.ModelView, components: azdata.Component[], label: azdata.Component, input: azdata.Component, labelPosition?: LabelPosition, additionalComponents?: azdata.Component[]) { + const inputs = [label, input]; + if (additionalComponents && additionalComponents.length > 0) { + inputs.push(...additionalComponents); + } if (labelPosition && labelPosition === LabelPosition.Left) { - const row = createFlexContainer(view, [label, input]); + const row = createFlexContainer(view, inputs); components.push(row); } else { - components.push(label, input); + components.push(...inputs); } } @@ -253,6 +285,9 @@ function processField(context: FieldContext): void { case FieldType.Options: processOptionsTypeField(context); break; + case FieldType.RadioOptions: + processRadioOptionsTypeField(context); + break; case FieldType.DateTimeText: processDateTimeTextField(context); break; @@ -275,6 +310,15 @@ function processField(context: FieldContext): void { case FieldType.AzureAccount: processAzureAccountField(context); break; + case FieldType.AzureLocations: + processAzureLocationsField(context); + break; + case FieldType.FilePicker: + processFilePickerField(context); + break; + case FieldType.KubeClusterContextPicker: + processKubeConfigClusterPickerField(context); + break; default: throw new Error(localize('UnknownFieldTypeError', "Unknown field type: \"{0}\"", context.fieldInfo.type)); } @@ -416,8 +460,9 @@ function processPasswordField(context: FieldContext): void { } function processReadonlyTextField(context: FieldContext): void { + let defaultValue = context.fieldInfo.defaultValue || ''; const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: false, width: context.fieldInfo.labelWidth, fontWeight: context.fieldInfo.labelFontWeight }); - const text = createLabel(context.view, { text: context.fieldInfo.defaultValue!, description: '', required: false, width: context.fieldInfo.inputWidth, fontStyle: context.fieldInfo.fontStyle, links: context.fieldInfo.links }); + const text = createLabel(context.view, { text: defaultValue, description: '', required: false, width: context.fieldInfo.inputWidth, fontWeight: context.fieldInfo.textFontWeight, fontStyle: context.fieldInfo.fontStyle, links: context.fieldInfo.links }); addLabelInputPairToContainer(context.view, context.components, label, text, context.fieldInfo.labelPosition); } @@ -427,23 +472,168 @@ function processCheckboxField(context: FieldContext): void { context.onNewInputComponentCreated(context.fieldInfo.variableName!, checkbox); } +/** + * A File Picker field consists of a text field and a browse button that allows a user to pick a file system file. + * @param context The context to use to create the field + */ +function processFilePickerField(context: FieldContext, defaultFilePath?: string): FilePickerInputs { + const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, fontWeight: context.fieldInfo.labelFontWeight }); + const input = createTextInput(context.view, { + defaultValue: defaultFilePath || context.fieldInfo.defaultValue || '', + ariaLabel: context.fieldInfo.label, + required: context.fieldInfo.required, + placeHolder: context.fieldInfo.placeHolder, + width: context.fieldInfo.inputWidth, + enabled: context.fieldInfo.enabled + }); + context.onNewInputComponentCreated(context.fieldInfo.variableName!, input); + input.enabled = false; + const browseFileButton = context.view!.modelBuilder.button().withProperties({ label: loc.browse }).component(); + context.onNewDisposableCreated(browseFileButton.onDidClick(async () => { + let fileUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: vscode.Uri.file(path.dirname(input.value || os_homedir())), + openLabel: loc.select, + filters: { + 'Config Files': ['*'], + } + }); + if (!fileUris || fileUris.length === 0) { + return; + } + let fileUri = fileUris[0]; + input.value = fileUri.fsPath; + })); + addLabelInputPairToContainer(context.view, context.components, label, input, LabelPosition.Left, [browseFileButton]); + return { input: input, browseButton: browseFileButton }; +} + +/** + * An Kube Config Cluster picker field consists of a file system filee picker and radio button selector for cluster contexts defined in the config filed picked using the file picker. + * @param context The context to use to create the field + */ +async function processKubeConfigClusterPickerField(context: KubeClusterContextFieldContext): Promise { + const kubeConfigFilePathVariableName = context.fieldInfo.configFileVariableName || 'AZDATA_NB_VAR_KUBECONFIG_PATH'; + const filePickerContext: FieldContext = { + container: context.container, + components: context.components, + view: context.view, + onNewValidatorCreated: context.onNewValidatorCreated, + onNewDisposableCreated: context.onNewDisposableCreated, + onNewInputComponentCreated: context.onNewInputComponentCreated, + fieldInfo: { + label: loc.kubeConfigFilePath, + type: FieldType.FilePicker, + labelWidth: context.fieldInfo.labelWidth, + variableName: kubeConfigFilePathVariableName, + required: true + } + }; + const filePicker = processFilePickerField(filePickerContext, getDefaultKubeConfigPath()); + context.fieldInfo.subFields = context.fieldInfo.subFields || []; + context.fieldInfo.subFields!.push({ + label: filePickerContext.fieldInfo.label, + variableName: kubeConfigFilePathVariableName + }); + context.onNewInputComponentCreated(kubeConfigFilePathVariableName, filePicker.input); + const getClusterContexts = async () => { + try { + let currentClusterContext = ''; + const clusterContexts: string[] = (await getKubeConfigClusterContexts(filePicker.input.value!)).map(kubeClusterContext => { + if (kubeClusterContext.isCurrentContext) { + currentClusterContext = kubeClusterContext.name; + } + return kubeClusterContext.name; + }); + if (clusterContexts.length === 0) { + throw Error(loc.clusterContextNotFound); + } + return { values: clusterContexts, defaultValue: currentClusterContext }; + } catch (e) { + throw Error(localize('kubeConfigClusterPicker.errorLoadingClusters', "An error ocurred while loading or parsing the config file:{0}, error is:{1}", filePicker.input.value, getErrorMessage(e))); + } + }; + + createRadioOptions(context, getClusterContexts) + .then(clusterContextOptions => { + filePicker.input.onTextChanged(async () => { + await loadOrReloadRadioOptions(context, clusterContextOptions.optionsList, clusterContextOptions.loader, getClusterContexts); + }); + }).catch(error => { + console.log(`failed to create radio options, Error: ${error}`); + }); +} + +async function processRadioOptionsTypeField(context: FieldContext): Promise { + return await createRadioOptions(context); +} + +async function createRadioOptions(context: FieldContext, getRadioButtonInfo?: (() => Promise<{ values: string[] | azdata.CategoryValue[], defaultValue: string }>)) + : Promise { + const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, fontWeight: context.fieldInfo.labelFontWeight }); + const optionsList = context.view!.modelBuilder.divContainer().withProperties({ clickable: false }).component(); + const radioOptionsLoadingComponent = context.view!.modelBuilder.loadingComponent().withItem(optionsList).component(); + addLabelInputPairToContainer(context.view, context.components, label, radioOptionsLoadingComponent, LabelPosition.Left); + await loadOrReloadRadioOptions(context, optionsList, radioOptionsLoadingComponent, getRadioButtonInfo); + return { optionsList: optionsList, loader: radioOptionsLoadingComponent }; +} + +async function loadOrReloadRadioOptions(context: FieldContext, optionsList: azdata.DivContainer, radioOptionsLoadingComponent: azdata.LoadingComponent, getRadioButtonInfo: (() => Promise<{ values: string[] | azdata.CategoryValue[]; defaultValue: string; }>) | undefined): Promise { + radioOptionsLoadingComponent.loading = true; + optionsList.clearItems(); + let options: (string[] | azdata.CategoryValue[]) = context.fieldInfo.options!; + let defaultValue: string = context.fieldInfo.defaultValue!; + try { + if (getRadioButtonInfo) { + const radioButtonsInfo = await getRadioButtonInfo(); + options = radioButtonsInfo.values; + defaultValue = radioButtonsInfo.defaultValue; + } + options.forEach((op: string | azdata.CategoryValue) => { + const option: azdata.CategoryValue = (typeof op === 'string') ? { name: op, displayName: op } : op as azdata.CategoryValue; + const radioOption = context.view!.modelBuilder.radioButton().withProperties({ + label: option.displayName, + checked: option.displayName === defaultValue, + name: option.name, + }).component(); + if (radioOption.checked) { + context.onNewInputComponentCreated(context.fieldInfo.variableName!, radioOption); + } + context.onNewDisposableCreated(radioOption.onDidClick(() => { + // reset checked status of all remaining radioButtons + optionsList.items.filter(otherOption => otherOption !== radioOption).forEach(otherOption => (otherOption as azdata.RadioButtonComponent).checked = false); + context.onNewInputComponentCreated(context.fieldInfo.variableName!, radioOption!); + })); + optionsList.addItem(radioOption); + }); + } + catch (e) { + const errorLoadingRadioOptionsLabel = context.view!.modelBuilder.text().withProperties({ value: getErrorMessage(e) }).component(); + optionsList.addItem(errorLoadingRadioOptionsLabel); + } + radioOptionsLoadingComponent.loading = false; +} + /** * An Azure Account field consists of 3 separate dropdown fields - Account, Subscription and Resource Group * @param context The context to use to create the field */ function processAzureAccountField(context: AzureAccountFieldContext): void { + context.fieldInfo.subFields = []; const accountValueToAccountMap = new Map(); const subscriptionValueToSubscriptionMap = new Map(); const accountDropdown = createAzureAccountDropdown(context); const subscriptionDropdown = createAzureSubscriptionDropdown(context, subscriptionValueToSubscriptionMap); const resourceGroupDropdown = createAzureResourceGroupsDropdown(context, accountDropdown, accountValueToAccountMap, subscriptionDropdown, subscriptionValueToSubscriptionMap); - const locationDropdown = createAzureLocationDropdown(context); + const locationDropdown = context.fieldInfo.locations && processAzureLocationsField(context); accountDropdown.onValueChanged(selectedItem => { const selectedAccount = accountValueToAccountMap.get(selectedItem.selected)!; handleSelectedAccountChanged(context, selectedAccount, subscriptionDropdown, subscriptionValueToSubscriptionMap, resourceGroupDropdown, locationDropdown); }); azdata.accounts.getAllAccounts().then((accounts: azdata.Account[]) => { - // Append a blank value for the "default" option if the field isn't required, this will clear all the dropdowns when selected + // Append a blank value for the "default" option if the field isn't required, context will clear all the dropdowns when selected const dropdownValues = context.fieldInfo.required ? [] : ['']; accountDropdown.values = dropdownValues.concat(accounts.map(account => { const displayName = `${account.displayInfo.displayName} (${account.displayInfo.userId})`; @@ -489,6 +679,10 @@ function createAzureSubscriptionDropdown( required: context.fieldInfo.required, label: loc.subscription }); + context.fieldInfo.subFields!.push({ + label: label.value!, + variableName: context.fieldInfo.subscriptionVariableName + }); context.onNewInputComponentCreated(context.fieldInfo.subscriptionVariableName!, subscriptionDropdown, (inputValue: string) => { return subscriptionValueToSubscriptionMap.get(inputValue)?.id || inputValue; }); @@ -502,18 +696,20 @@ function handleSelectedAccountChanged( subscriptionDropdown: azdata.DropDownComponent, subscriptionValueToSubscriptionMap: Map, resourceGroupDropdown: azdata.DropDownComponent, - locationDropdown: azdata.DropDownComponent + locationDropdown?: azdata.DropDownComponent ): void { subscriptionValueToSubscriptionMap.clear(); subscriptionDropdown.values = []; handleSelectedSubscriptionChanged(context, selectedAccount, undefined, resourceGroupDropdown); - if (selectedAccount) { - if (locationDropdown.values && locationDropdown.values.length === 0) { - locationDropdown.values = context.fieldInfo.locations; + if (locationDropdown) { + if (selectedAccount) { + if (locationDropdown.values && locationDropdown.values.length === 0) { + locationDropdown.values = context.fieldInfo.locations; + } + } else { + locationDropdown.values = []; + return; } - } else { - locationDropdown.values = []; - return; } vscode.commands.executeCommand('azure.accounts.getSubscriptions', selectedAccount, true /*ignoreErrors*/).then(response => { if (!response) { @@ -554,6 +750,10 @@ function createAzureResourceGroupsDropdown( required: context.fieldInfo.required, label: loc.resourceGroup }); + context.fieldInfo.subFields!.push({ + label: label.value!, + variableName: context.fieldInfo.resourceGroupVariableName + }); context.onNewInputComponentCreated(context.fieldInfo.resourceGroupVariableName!, resourceGroupDropdown); addLabelInputPairToContainer(context.view, context.components, label, resourceGroupDropdown, context.fieldInfo.labelPosition); subscriptionDropdown.onValueChanged(selectedItem => { @@ -594,9 +794,13 @@ const knownAzureLocationNameMappings = new Map([ ['Central US', 'centralus'] ]); -function createAzureLocationDropdown(context: AzureAccountFieldContext): azdata.DropDownComponent { +/** + * An Azure Locations field consists of a dropdown field for azure locations + * @param context The context to use to create the field + */ +function processAzureLocationsField(context: AzureLocationsFieldContext): azdata.DropDownComponent { const label = createLabel(context.view, { - text: loc.location, + text: context.fieldInfo.label || loc.location, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, fontWeight: context.fieldInfo.labelFontWeight @@ -608,7 +812,24 @@ function createAzureLocationDropdown(context: AzureAccountFieldContext): azdata. label: loc.location, values: context.fieldInfo.locations }); - context.onNewInputComponentCreated(context.fieldInfo.locationVariableName!, locationDropdown, (inputValue: string) => { + context.fieldInfo.subFields = context.fieldInfo.subFields || []; + if (context.fieldInfo.locationVariableName) { + context.fieldInfo.subFields!.push({ + label: label.value!, + variableName: context.fieldInfo.locationVariableName + }); + context.onNewInputComponentCreated(context.fieldInfo.locationVariableName, locationDropdown, (inputValue: string) => { + return knownAzureLocationNameMappings.get(inputValue) || inputValue; + }); + } + if (context.fieldInfo.displayLocationVariableName) { + context.fieldInfo.subFields!.push({ + label: label.value!, + variableName: context.fieldInfo.displayLocationVariableName + }); + context.onNewInputComponentCreated(context.fieldInfo.displayLocationVariableName, locationDropdown); + } + context.onNewInputComponentCreated(context.fieldInfo.variableName!, locationDropdown, (inputValue: string) => { return knownAzureLocationNameMappings.get(inputValue) || inputValue; }); addLabelInputPairToContainer(context.view, context.components, label, locationDropdown, context.fieldInfo.labelPosition); @@ -618,12 +839,12 @@ function createAzureLocationDropdown(context: AzureAccountFieldContext): azdata. export function isValidSQLPassword(password: string, userName: string = 'sa'): boolean { // Validate SQL Server password const containsUserName = password && userName !== undefined && password.toUpperCase().includes(userName.toUpperCase()); - // Instead of using one RegEx, I am seperating it to make it more readable. + // Instead of using one RegEx, I am separating it to make it more readable. const hasUpperCase = /[A-Z]/.test(password) ? 1 : 0; const hasLowerCase = /[a-z]/.test(password) ? 1 : 0; const hasNumbers = /\d/.test(password) ? 1 : 0; - const hasNonalphas = /\W/.test(password) ? 1 : 0; - return !containsUserName && password.length >= 8 && password.length <= 128 && (hasUpperCase + hasLowerCase + hasNumbers + hasNonalphas >= 3); + const hasNonAlphas = /\W/.test(password) ? 1 : 0; + return !containsUserName && password.length >= 8 && password.length <= 128 && (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphas >= 3); } export function removeValidationMessage(container: azdata.window.Dialog | azdata.window.Wizard, message: string): void { @@ -646,7 +867,9 @@ export function setModelValues(inputComponents: InputComponents, model: Model): Object.keys(inputComponents).forEach(key => { let value; const input = inputComponents[key].component; - if ('checked' in input) { // CheckBoxComponent + if ('name' in input && 'checked' in input) { //RadioButtonComponent + value = input.name; + } else if ('checked' in input) { // CheckBoxComponent value = input.checked ? 'true' : 'false'; } else if ('value' in input) { // InputBoxComponent or DropDownComponent const inputValue = input.value; @@ -671,4 +894,3 @@ export function isInputBoxEmpty(input: azdata.InputBoxComponent): boolean { return input.value === undefined || input.value === ''; } -export const MissingRequiredInformationErrorMessage = localize('deployCluster.MissingRequiredInfoError', "Please fill out the required fields marked with red asterisks."); diff --git a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts new file mode 100644 index 0000000000..2bfa0a2ef1 --- /dev/null +++ b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizard.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * 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 nls from 'vscode-nls'; +import { INotebookService } from '../../services/notebookService'; +import { Model } from '../model'; +import { WizardBase } from '../wizardBase'; +import { WizardPageBase } from '../wizardPageBase'; +import { DeploymentType, NotebookWizardInfo } from './../../interfaces'; +import { IPlatformService } from './../../services/platformService'; +import { NotebookWizardPage } from './notebookWizardPage'; +import { NotebookWizardSummaryPage } from './notebookWizardSummaryPage'; + +const localize = nls.loadMessageBundle(); + +export class NotebookWizard extends WizardBase { + + public get notebookService(): INotebookService { + return this._notebookService; + } + + public get platformService(): IPlatformService { + return this._platformService; + } + + public get wizardInfo(): NotebookWizardInfo { + return this._wizardInfo; + } + + constructor(private _wizardInfo: NotebookWizardInfo, private _notebookService: INotebookService, private _platformService: IPlatformService) { + super(_wizardInfo.title, new Model()); + this.wizardObject.doneButton.label = _wizardInfo.actionText || this.wizardObject.doneButton.label; + } + + public get deploymentType(): DeploymentType | undefined { + return this._wizardInfo.type; + } + + protected initialize(): void { + this.setPages(this.getPages()); + this.wizardObject.generateScriptButton.hidden = true; + this.wizardInfo.actionText = this.wizardInfo.actionText || localize('deployCluster.ScriptToNotebook', "Script to Notebook"); + this.wizardObject.doneButton.label = this.wizardInfo.actionText; + } + + protected onCancel(): void { + } + + protected onOk(): void { + this.model.setEnvironmentVariables(); + if (this.wizardInfo.runNotebook) { + this.notebookService.backgroundExecuteNotebook(this.wizardInfo.taskName, this.wizardInfo.notebook, 'deploy', this.platformService); + } else { + this.notebookService.launchNotebook(this.wizardInfo.notebook).then(() => { }, (error) => { + vscode.window.showErrorMessage(error); + }); + } + } + + private getPages(): WizardPageBase[] { + const pages: WizardPageBase[] = []; + for (let pageIndex: number = 0; pageIndex < this.wizardInfo.pages.length; pageIndex++) { + pages.push(new NotebookWizardPage(this, pageIndex)); + } + if (this.wizardInfo.generateSummaryPage) { + pages.push(new NotebookWizardSummaryPage(this)); + } + return pages; + } +} diff --git a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts new file mode 100644 index 0000000000..b2ae551714 --- /dev/null +++ b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardPage.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { EOL } from 'os'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { NotebookWizardPageInfo } from '../../interfaces'; +import { initializeWizardPage, InputComponents, InputComponent, setModelValues, Validator } from '../modelViewUtils'; +import { WizardPageBase } from '../wizardPageBase'; +import { NotebookWizard } from './notebookWizard'; + +const localize = nls.loadMessageBundle(); + +export class NotebookWizardPage extends WizardPageBase { + private inputComponents: InputComponents = {}; + + protected get pageInfo(): NotebookWizardPageInfo { + return this.wizard.wizardInfo.pages[this._pageIndex]; + } + + constructor(wizard: NotebookWizard, private _pageIndex: number) { + super(wizard.wizardInfo.pages[_pageIndex].title, wizard.wizardInfo.pages[_pageIndex].description || '', wizard); + } + + public initialize(): void { + const self = this; + initializeWizardPage({ + container: this.wizard.wizardObject, + wizardInfo: this.wizard.wizardInfo, + pageInfo: this.pageInfo, + page: this.pageObject, + onNewDisposableCreated: (disposable: vscode.Disposable): void => { + self.wizard.registerDisposable(disposable); + }, + onNewInputComponentCreated: (name: string, component: InputComponent): void => { + self.inputComponents[name] = { component: component }; + }, + onNewValidatorCreated: (validator: Validator): void => { + self.validators.push(validator); + } + }); + } + + + public onLeave() { + setModelValues(this.inputComponents, this.wizard.model); + // The following callback registration clears previous navigation validators. + this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { + return true; + }); + } + + public onEnter() { + this.wizard.wizardObject.registerNavigationValidator((pcInfo) => { + this.wizard.wizardObject.message = { text: '' }; + if (pcInfo.newPage > pcInfo.lastPage) { + const messages: string[] = []; + + this.validators.forEach(validator => { + const result = validator(); + if (!result.valid) { + messages.push(result.message); + } + }); + + if (messages.length > 0) { + this.wizard.wizardObject.message = { + text: messages.length === 1 ? messages[0] : localize('wizardPage.ValidationError', "There are some errors on this page, click 'Show Details' to view the errors."), + description: messages.length === 1 ? undefined : messages.join(EOL), + level: azdata.window.MessageLevel.Error + }; + } + return messages.length === 0; + } + return true; + }); + } +} diff --git a/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardSummaryPage.ts b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardSummaryPage.ts new file mode 100644 index 0000000000..b32f6b0ae1 --- /dev/null +++ b/extensions/resource-deployment/src/ui/notebookWizard/notebookWizardSummaryPage.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * 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 nls from 'vscode-nls'; + +import { SubFieldInfo, FieldType, FontWeight, LabelPosition, SectionInfo } from '../../interfaces'; +import { createSection, DefaultInputComponentWidth, DefaultLabelComponentWidth } from '../modelViewUtils'; +import { WizardPageBase } from '../wizardPageBase'; +import { NotebookWizard } from './notebookWizard'; + +const localize = nls.loadMessageBundle(); + +export class NotebookWizardSummaryPage extends WizardPageBase { + private formItems: azdata.FormComponent[] = []; + private form!: azdata.FormBuilder; + private view!: azdata.ModelView; + + constructor(wizard: NotebookWizard) { + super(localize('notebookWizard.summaryPageTitle', "Review your configuration"), '', wizard); + } + + public initialize(): void { + this.pageObject.registerContent((view: azdata.ModelView) => { + this.view = view; + this.form = view.modelBuilder.formContainer(); + return view.initializeModel(this.form!.withLayout({ width: '100%' }).component()); + }); + } + + public onLeave() { + this.wizard.wizardObject.message = { text: '' }; + } + + public onEnter() { + this.formItems.forEach(item => { + this.form!.removeFormItem(item); + }); + this.formItems = []; + + const inputWidth = this.wizard.wizardInfo.inputWidth || (this.wizard.wizardInfo.summaryPage && this.wizard.wizardInfo.summaryPage.inputWidth) || DefaultInputComponentWidth; + const labelWidth = this.wizard.wizardInfo.labelWidth || (this.wizard.wizardInfo.summaryPage && this.wizard.wizardInfo.summaryPage.labelWidth) || DefaultLabelComponentWidth; + const labelPosition = this.wizard.wizardInfo.labelPosition || (this.wizard.wizardInfo.summaryPage && this.wizard.wizardInfo.summaryPage.labelPosition) || LabelPosition.Left; + + this.wizard.wizardInfo.pages.forEach(pageInfo => { + const summarySectionInfo: SectionInfo = { + labelPosition: labelPosition, + labelWidth: labelWidth, + inputWidth: inputWidth, + title: '', + rows: [] + }; + pageInfo.sections.forEach(sectionInfo => { + sectionInfo.fields!.forEach(fieldInfo => { + if (fieldInfo.variableName) { + this.addSummaryForVariable(summarySectionInfo, fieldInfo); + } + if (fieldInfo.subFields) { + fieldInfo.subFields.forEach(subFieldInfo => { + this.addSummaryForVariable(summarySectionInfo, subFieldInfo); + }); + } + }); + }); + if (summarySectionInfo!.rows!.length > 0) { + const formComponent: azdata.FormComponent = { + title: pageInfo.title, + component: createSection({ + container: this.wizard.wizardObject, + sectionInfo: summarySectionInfo, + view: this.view, + onNewDisposableCreated: () => { }, + onNewInputComponentCreated: () => { }, + onNewValidatorCreated: () => { } + }) + }; + this.formItems.push(formComponent); + } + }); + this.form.addFormItems(this.formItems); + + } + + private addSummaryForVariable(summarySectionInfo: SectionInfo, fieldInfo: SubFieldInfo) { + summarySectionInfo!.rows!.push({ + fields: [{ + type: FieldType.ReadonlyText, + label: fieldInfo.label, + defaultValue: this.wizard.model.getStringValue(fieldInfo.variableName!), + labelFontWeight: FontWeight.Bold + }] + }); + } +} diff --git a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts index d18f18eb7d..6efaabaa83 100644 --- a/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts +++ b/extensions/resource-deployment/src/ui/resourceTypePickerDialog.ts @@ -173,24 +173,26 @@ export class ResourceTypePickerDialog extends DialogBase { this._optionsContainer.clearItems(); this._optionDropDownMap.clear(); - resourceType.options.forEach(option => { - const optionLabel = this._view.modelBuilder.text().withProperties({ - value: option.displayName - }).component(); - optionLabel.width = '150px'; + if (resourceType.options) { + resourceType.options.forEach(option => { + const optionLabel = this._view.modelBuilder.text().withProperties({ + value: option.displayName + }).component(); + optionLabel.width = '150px'; - const optionSelectBox = this._view.modelBuilder.dropDown().withProperties({ - values: option.values, - value: option.values[0], - width: '300px', - ariaLabel: option.displayName - }).component(); + const optionSelectBox = this._view.modelBuilder.dropDown().withProperties({ + values: option.values, + value: option.values[0], + width: '300px', + ariaLabel: option.displayName + }).component(); - this._toDispose.push(optionSelectBox.onValueChanged(() => { this.updateToolsDisplayTable(); })); - this._optionDropDownMap.set(option.name, optionSelectBox); - const row = this._view.modelBuilder.flexContainer().withItems([optionLabel, optionSelectBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '20px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); - this._optionsContainer.addItem(row); - }); + this._toDispose.push(optionSelectBox.onValueChanged(() => { this.updateToolsDisplayTable(); })); + this._optionDropDownMap.set(option.name, optionSelectBox); + const row = this._view.modelBuilder.flexContainer().withItems([optionLabel, optionSelectBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '20px' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); + this._optionsContainer.addItem(row); + }); + } this.updateToolsDisplayTable(); } diff --git a/extensions/resource-deployment/src/utils.ts b/extensions/resource-deployment/src/utils.ts index 6d80844764..216e23165e 100644 --- a/extensions/resource-deployment/src/utils.ts +++ b/extensions/resource-deployment/src/utils.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ITool, NoteBookEnvironmentVariablePrefix } from './interfaces'; import * as path from 'path'; -import { ToolsInstallPath } from './ui/deployClusterWizard/constants'; +import { ToolsInstallPath } from './constants'; export function getErrorMessage(error: any): string { return (error instanceof Error)