diff --git a/extensions/machine-learning-services/package.json b/extensions/machine-learning-services/package.json index 00a7e9acdd..6b01eb0484 100644 --- a/extensions/machine-learning-services/package.json +++ b/extensions/machine-learning-services/package.json @@ -10,7 +10,7 @@ "azdata": ">=1.13.0" }, "activationEvents": [ - "onCommand:ml.command.managePackages" + "*" ], "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/master/LICENSE.txt", "icon": "images/ML_ExtensionIcon.png", @@ -27,28 +27,66 @@ "contributes": { "commands": [ { - "command": "ml.command.managePackages", - "title": "%ml.command.managePackages%" + "command": "mls.command.managePackages", + "title": "%mls.command.managePackages%" + }, + { + "command": "mls.command.odbcdriver", + "title": "%mls.command.odbcdriver%" + }, + { + "command": "mls.command.mlsdocs", + "title": "%mls.command.mlsdocs%" + } + ], + "dashboard.tabs": [ + { + "id": "mls-dashboard", + "description": "%description%", + "provider": "MSSQL", + "title": "%displayName%", + "when": "connectionProvider == 'MSSQL' && !mssql:iscloud", + "container": { + "grid-container": [ + { + "name": "%title.configurations%", + "row": 0, + "col": 0, + "widget": { + "modelview": { + "id": "ml.tasks" + } + } + }, + { + "name": "%title.tasks%", + "row": 0, + "col": 1, + "widget": { + "tasks-widget": [ + "mls.command.managePackages", + "mls.command.odbcdriver", + "mls.command.mlsdocs" + ] + } + } + ] + } } ] }, "dependencies": { - "vscode-nls": "^4.0.0" + "vscode-nls": "^4.0.0", + "vscode-languageclient": "^5.3.0-next.1" }, "devDependencies": { "@types/mocha": "^5.2.5", "@types/node": "^10.14.8", - "@types/uuid": "^3.4.5", "mocha": "^5.2.0", "mocha-junit-reporter": "^1.17.0", "mocha-multi-reporters": "^1.1.7", "should": "^13.2.1", "typemoq": "^2.1.0", "vscode": "1.1.26" - }, - "__metadata": { - "id": "56", - "publisherDisplayName": "Microsoft", - "publisherId": "Microsoft" - } + } } diff --git a/extensions/machine-learning-services/package.nls.json b/extensions/machine-learning-services/package.nls.json index 2e3ddd2788..01aabf4d0e 100644 --- a/extensions/machine-learning-services/package.nls.json +++ b/extensions/machine-learning-services/package.nls.json @@ -1,10 +1,11 @@ { "displayName": "SQL Server Machine Learning Services", "description": "SQL Server Machine Learning Services", - "mlServices.enable": "Enable Machine Learning Services", - "mlServices.disable": "Disable Machine Learning Services", - "title.tasks": "Getting Started", + "title.tasks": "Tasks", + "title.configurations": "Configurations", "title.endpoints": "Endpoints", "title.books": "Machine Learning Services Books", - "ml.command.managePackages": "Manage Packages in SQL Server" + "mls.command.managePackages": "Manage Packages in SQL Server", + "mls.command.odbcdriver": "Install ODBC Driver for SQL Server", + "mls.command.mlsdocs": "Machine Learning Services Documentation" } diff --git a/extensions/machine-learning-services/src/common/apiWrapper.ts b/extensions/machine-learning-services/src/common/apiWrapper.ts index 6f390e3326..3548a5f6d4 100644 --- a/extensions/machine-learning-services/src/common/apiWrapper.ts +++ b/extensions/machine-learning-services/src/common/apiWrapper.ts @@ -34,6 +34,9 @@ export class ApiWrapper { public executeCommand(command: string, ...rest: any[]): Thenable { return vscode.commands.executeCommand(command, ...rest); } + public registerTaskHandler(taskId: string, handler: (profile: azdata.IConnectionProfile) => void): void { + azdata.tasks.registerTask(taskId, handler); + } public getUriForConnection(connectionId: string): Thenable { return azdata.connection.getUriForConnection(connectionId); @@ -59,6 +62,10 @@ export class ApiWrapper { azdata.tasks.startBackgroundOperation(operationInfo); } + public openExternal(target: vscode.Uri): Thenable { + return vscode.env.openExternal(target); + } + public getExtension(extensionId: string): vscode.Extension | undefined { return vscode.extensions.getExtension(extensionId); } diff --git a/extensions/machine-learning-services/src/common/constants.ts b/extensions/machine-learning-services/src/common/constants.ts index d156cddb75..f40cf08ba8 100644 --- a/extensions/machine-learning-services/src/common/constants.ts +++ b/extensions/machine-learning-services/src/common/constants.ts @@ -12,17 +12,65 @@ const localize = nls.loadMessageBundle(); export const winPlatform = 'win32'; export const pythonBundleVersion = '0.0.1'; export const managePackagesCommand = 'jupyter.cmd.managePackages'; -export const mlManagePackagesCommand = 'ml.command.managePackages'; +export const pythonLanguageName = 'Python'; +export const rLanguageName = 'R'; + +export const mlEnableMlsCommand = 'mls.command.enableMls'; +export const mlDisableMlsCommand = 'mls.command.disableMls'; export const extensionOutputChannel = 'Machine Learning Services'; export const notebookExtensionName = 'Microsoft.notebook'; +// Tasks, commands +// +export const mlManagePackagesCommand = 'mls.command.managePackages'; +export const mlOdbcDriverCommand = 'mls.command.odbcdriver'; +export const mlsDocumentsCommand = 'mls.command.mlsdocs'; + // Localized texts // -export const managePackageCommandError = localize('ml.managePackages.error', "Either no connection is available or the server does not have external script enabled."); -export function installDependenciesError(err: string): string { return localize('ml.installDependencies.error', "Failed to install dependencies. Error: {0}", err); } -export const installDependenciesMsgTaskName = localize('ml.installDependencies.msgTaskName', "Installing Machine Learning extension dependencies"); -export const installDependenciesPackages = localize('ml.installDependencies.packages', "Installing required packages ..."); -export const installDependenciesPackagesAlreadyInstalled = localize('ml.installDependencies.packagesAlreadyInstalled', "Required packages are already installed."); -export function installDependenciesGetPackagesError(err: string): string { return localize('ml.installDependencies.getPackagesError', "Failed to get installed python packages. Error: {0}", err); } -export const packageManagerNoConnection = localize('ml.packageManager.NoConnection', "No connection selected"); -export const notebookExtensionNotLoaded = localize('ml.notebookExtensionNotLoaded', "Notebook extension is not loaded"); +export const managePackageCommandError = localize('mls.managePackages.error', "Either no connection is available or the server does not have external script enabled."); +export function installDependenciesError(err: string): string { return localize('mls.installDependencies.error', "Failed to install dependencies. Error: {0}", err); } +export const installDependenciesMsgTaskName = localize('mls.installDependencies.msgTaskName', "Installing Machine Learning extension dependencies"); +export const installDependenciesPackages = localize('mls.installDependencies.packages', "Installing required packages ..."); +export const installDependenciesPackagesAlreadyInstalled = localize('mls.installDependencies.packagesAlreadyInstalled', "Required packages are already installed."); +export function installDependenciesGetPackagesError(err: string): string { return localize('mls.installDependencies.getPackagesError', "Failed to get installed python packages. Error: {0}", err); } +export const packageManagerNoConnection = localize('mls.packageManager.NoConnection', "No connection selected"); +export const notebookExtensionNotLoaded = localize('mls.notebookExtensionNotLoaded', "Notebook extension is not loaded"); +export const mlsEnabledMessage = localize('mls.enabledMessage', "Machine Learning Services Enabled"); +export const mlsDisabledMessage = localize('mls.disabledMessage', "Machine Learning Services Disabled"); +export const mlsConfigUpdateFailed = localize('mls.configUpdateFailed', "Failed to modify Machine Learning Services configurations"); +export const mlsEnableButtonTitle = localize('mls.enableButtonTitle', "Enable"); +export const mlsDisableButtonTitle = localize('mls.disableButtonTitle', "Disable"); +export const mlsConfigTitle = localize('mls.configTitle', "Config"); +export const mlsConfigStatus = localize('mls.configStatus', "Enabled"); +export const mlsConfigAction = localize('mls.configAction', "Action"); +export const mlsExternalExecuteScriptTitle = localize('mls.externalExecuteScriptTitle', "External Execute Script"); +export const mlsPythonLanguageTitle = localize('mls.pythonLanguageTitle', "Python"); +export const mlsRLanguageTitle = localize('mls.rLanguageTitle', "R"); + +// Links +// +export const mlsDocuments = 'https://docs.microsoft.com/sql/advanced-analytics/?view=sql-server-ver15'; +export const odbcDriverWindowsDocuments = 'https://docs.microsoft.com/sql/connect/odbc/windows/microsoft-odbc-driver-for-sql-server-on-windows?view=sql-server-ver15'; +export const odbcDriverLinuxDocuments = 'https://docs.microsoft.com/sql/connect/odbc/linux-mac/installing-the-microsoft-odbc-driver-for-sql-server?view=sql-server-ver15'; +export const installMlsLinuxDocs = 'https://docs.microsoft.com/sql/linux/sql-server-linux-setup-machine-learning?toc=%2fsql%2fadvanced-analytics%2ftoc.json&view=sql-server-ver15'; +export const installMlsWindowsDocs = 'https://docs.microsoft.com/sql/advanced-analytics/install/sql-machine-learning-services-windows-install?view=sql-server-ver15'; + +// CSS Styles +// +export namespace cssStyles { + export const title = { 'font-size': '14px', 'font-weight': '600' }; + export const tableHeader = { 'text-align': 'left', 'font-weight': 'bold', 'text-transform': 'uppercase', 'font-size': '10px', 'user-select': 'text', 'border': 'none', 'background-color': '#FFFFFF' }; + export const tableRow = { 'border-top': 'solid 1px #ccc', 'border-bottom': 'solid 1px #ccc', 'border-left': 'none', 'border-right': 'none' }; + export const hyperlink = { 'user-select': 'text', 'color': '#0078d4', 'text-decoration': 'underline', 'cursor': 'pointer' }; + export const text = { 'margin-block-start': '0px', 'margin-block-end': '0px' }; + export const overflowEllipsisText = { ...text, 'overflow': 'hidden', 'text-overflow': 'ellipsis' }; + export const nonSelectableText = { ...cssStyles.text, 'user-select': 'none' }; + export const tabHeaderText = { 'margin-block-start': '2px', 'margin-block-end': '0px', 'user-select': 'none' }; + export const selectedResourceHeaderTab = { 'font-weight': 'bold', 'color': '' }; + export const unselectedResourceHeaderTab = { 'font-weight': '', 'color': '#0078d4' }; + export const selectedTabDiv = { 'border-bottom': '2px solid #000' }; + export const unselectedTabDiv = { 'border-bottom': '1px solid #ccc' }; + export const lastUpdatedText = { ...text, 'color': '#595959' }; + export const errorText = { ...text, 'color': 'red' }; +} diff --git a/extensions/machine-learning-services/src/common/processService.ts b/extensions/machine-learning-services/src/common/processService.ts index d17b14d6ba..c583840e83 100644 --- a/extensions/machine-learning-services/src/common/processService.ts +++ b/extensions/machine-learning-services/src/common/processService.ts @@ -11,10 +11,13 @@ import * as childProcess from 'child_process'; const ExecScriptsTimeoutInSeconds = 600000; export class ProcessService { + public timeout = ExecScriptsTimeoutInSeconds; + public async execScripts(exeFilePath: string, scripts: string[], outputChannel?: vscode.OutputChannel): Promise { return new Promise((resolve, reject) => { const scriptExecution = childProcess.spawn(exeFilePath); + let timer: NodeJS.Timeout; let output: string; scripts.forEach(script => { scriptExecution.stdin.write(`${script}\n`); @@ -34,19 +37,23 @@ export class ProcessService { } scriptExecution.on('exit', (code) => { + if (timer) { + clearTimeout(timer); + } if (code === 0) { resolve(); } else { reject(`Process exited with code: ${code}. output: ${output}`); } + }); - setTimeout(() => { + timer = setTimeout(() => { try { scriptExecution.kill(); } catch (error) { console.log(error); } - }, ExecScriptsTimeoutInSeconds); + }, this.timeout); }); } @@ -56,7 +63,9 @@ export class ProcessService { outputChannel.appendLine(` > ${cmd}`); } - let child = childProcess.exec(cmd, (err, stdout) => { + let child = childProcess.exec(cmd, { + timeout: this.timeout + }, (err, stdout) => { if (err) { reject(err); } else { diff --git a/extensions/machine-learning-services/src/common/queryRunner.ts b/extensions/machine-learning-services/src/common/queryRunner.ts index 398c2342aa..2c3507a85e 100644 --- a/extensions/machine-learning-services/src/common/queryRunner.ts +++ b/extensions/machine-learning-services/src/common/queryRunner.ts @@ -8,6 +8,7 @@ import * as azdata from 'azdata'; import * as nbExtensionApis from '../typings/notebookServices'; import { ApiWrapper } from './apiWrapper'; +import * as constants from '../common/constants'; const listPythonPackagesQuery = ` EXEC sp_execute_external_script @@ -25,11 +26,11 @@ Declare @external_script_enabled bit SELECT @external_script_enabled=config_value FROM @tablevar WHERE name = 'external scripts enabled' SELECT @external_script_enabled`; -const checkPythonInstalledQuery = ` +const checkLanguageInstalledQuery = ` SELECT is_installed FROM sys.dm_db_external_language_stats s, sys.external_languages l -WHERE s.external_language_id = l.external_language_id AND language = 'Python'`; +WHERE s.external_language_id = l.external_language_id AND language = '#LANGUAGE#'`; const modifyExternalScriptConfigQuery = ` @@ -86,7 +87,21 @@ export class QueryRunner { * Returns true if python installed in the give SQL server instance */ public async isPythonInstalled(connection: azdata.connection.ConnectionProfile): Promise { - let result = await this.runQuery(connection, checkPythonInstalledQuery); + return this.isLanguageInstalled(connection, constants.pythonLanguageName); + } + + /** + * Returns true if R installed in the give SQL server instance + */ + public async isRInstalled(connection: azdata.connection.ConnectionProfile): Promise { + return this.isLanguageInstalled(connection, constants.rLanguageName); + } + + /** + * Returns true if language installed in the give SQL server instance + */ + private async isLanguageInstalled(connection: azdata.connection.ConnectionProfile, language: string): Promise { + let result = await this.runQuery(connection, checkLanguageInstalledQuery.replace('#LANGUAGE#', language)); let isInstalled = false; if (result && result.rows && result.rows.length > 0) { isInstalled = result.rows[0][0].displayValue === '1'; diff --git a/extensions/machine-learning-services/src/common/utils.ts b/extensions/machine-learning-services/src/common/utils.ts index d23367a301..8cf07cd619 100644 --- a/extensions/machine-learning-services/src/common/utils.ts +++ b/extensions/machine-learning-services/src/common/utils.ts @@ -5,7 +5,7 @@ 'use strict'; -import * as uuid from 'uuid'; +import * as UUID from 'vscode-languageclient/lib/utils/uuid'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; @@ -15,7 +15,7 @@ import { promisify } from 'util'; export async function execCommandOnTempFile(content: string, command: (filePath: string) => Promise): Promise { let tempFilePath: string = ''; try { - tempFilePath = path.join(os.tmpdir(), `ads_ml_temp_${uuid.v4()}`); + tempFilePath = path.join(os.tmpdir(), `ads_ml_temp_${UUID.generateUuid()}`); await fs.promises.writeFile(tempFilePath, content); let result = await command(tempFilePath); return result; @@ -46,3 +46,7 @@ export function getPythonExePath(rootFolder: string): string { constants.pythonBundleVersion, process.platform === constants.winPlatform ? 'python.exe' : 'bin/python3'); } + +export function isWindows(): boolean { + return process.platform === 'win32'; +} diff --git a/extensions/machine-learning-services/src/controllers/mainController.ts b/extensions/machine-learning-services/src/controllers/mainController.ts index c551f4e4db..008a14f502 100644 --- a/extensions/machine-learning-services/src/controllers/mainController.ts +++ b/extensions/machine-learning-services/src/controllers/mainController.ts @@ -6,6 +6,7 @@ 'use strict'; import * as vscode from 'vscode'; + import * as nbExtensionApis from '../typings/notebookServices'; import { PackageManager } from '../packageManagement/packageManager'; import * as constants from '../common/constants'; @@ -13,12 +14,13 @@ import { ApiWrapper } from '../common/apiWrapper'; import { QueryRunner } from '../common/queryRunner'; import { ProcessService } from '../common/processService'; import { Config } from '../common/config'; +import { ServerConfigWidget } from '../widgets/serverConfigWidgets'; +import { ServerConfigManager } from '../serverConfig/serverConfigManager'; /** * The main controller class that initializes the extension */ export default class MainController implements vscode.Disposable { - private _outputChannel: vscode.OutputChannel; private _rootPath = this._context.extensionPath; private _config: Config; @@ -28,7 +30,8 @@ export default class MainController implements vscode.Disposable { private _apiWrapper: ApiWrapper, private _queryRunner: QueryRunner, private _processService: ProcessService, - private _packageManager?: PackageManager + private _packageManager?: PackageManager, + private _serverConfigManager?: ServerConfigManager ) { this._outputChannel = this._apiWrapper.createOutputChannel(constants.extensionOutputChannel); this._rootPath = this._context.extensionPath; @@ -63,14 +66,28 @@ export default class MainController implements vscode.Disposable { } private async initialize(): Promise { + this._outputChannel.show(true); let nbApis = await this.getNotebookExtensionApis(); await this._config.load(); + let tasks = new ServerConfigWidget(this._apiWrapper, this.serverConfigManager); + tasks.register(); + let packageManager = this.getPackageManager(nbApis); this._apiWrapper.registerCommand(constants.mlManagePackagesCommand, (async () => { await packageManager.managePackages(); })); + this._apiWrapper.registerTaskHandler(constants.mlManagePackagesCommand, async () => { + await packageManager.managePackages(); + }); + this._apiWrapper.registerTaskHandler(constants.mlOdbcDriverCommand, async () => { + await this.serverConfigManager.openOdbcDriverDocuments(); + }); + this._apiWrapper.registerTaskHandler(constants.mlsDocumentsCommand, async () => { + await this.serverConfigManager.openDocuments(); + }); + try { await packageManager.installDependencies(); } catch (err) { @@ -90,10 +107,13 @@ export default class MainController implements vscode.Disposable { } /** - * Package manager instance - */ - public set packageManager(value: PackageManager) { - this._packageManager = value; + * Returns the server config manager instance + */ + public get serverConfigManager(): ServerConfigManager { + if (!this._serverConfigManager) { + this._serverConfigManager = new ServerConfigManager(this._apiWrapper, this._queryRunner); + } + return this._serverConfigManager; } /** diff --git a/extensions/machine-learning-services/src/serverConfig/serverConfigManager.ts b/extensions/machine-learning-services/src/serverConfig/serverConfigManager.ts new file mode 100644 index 0000000000..1cabf51f42 --- /dev/null +++ b/extensions/machine-learning-services/src/serverConfig/serverConfigManager.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; +import { QueryRunner } from '../common/queryRunner'; +import * as constants from '../common/constants'; +import { ApiWrapper } from '../common/apiWrapper'; +import * as utils from '../common/utils'; + +export class ServerConfigManager { + + /** + * Creates a new instance of ServerConfigManager + */ + constructor( + private _apiWrapper: ApiWrapper, + private _queryRunner: QueryRunner, + ) { + } + + /** + * Opens server config documents + */ + public async openDocuments(): Promise { + return await this._apiWrapper.openExternal(vscode.Uri.parse(constants.mlsDocuments)); + } + + /** + * Opens ODBC driver documents + */ + public async openOdbcDriverDocuments(): Promise { + if (utils.isWindows()) { + return await this._apiWrapper.openExternal(vscode.Uri.parse(constants.odbcDriverWindowsDocuments)); + } else { + return await this._apiWrapper.openExternal(vscode.Uri.parse(constants.odbcDriverLinuxDocuments)); + } + } + + /** + * Opens install MLS documents + */ + public async openInstallDocuments(): Promise { + if (utils.isWindows()) { + return await this._apiWrapper.openExternal(vscode.Uri.parse(constants.installMlsWindowsDocs)); + } else { + return await this._apiWrapper.openExternal(vscode.Uri.parse(constants.installMlsLinuxDocs)); + } + } + + /** + * Returns true if mls is installed in the give SQL server instance + */ + public async isMachineLearningServiceEnabled(connection: azdata.connection.ConnectionProfile): Promise { + return this._queryRunner.isMachineLearningServiceEnabled(connection); + } + + /** + * Returns true if R installed in the give SQL server instance + */ + public async isRInstalled(connection: azdata.connection.ConnectionProfile): Promise { + return this._queryRunner.isRInstalled(connection); + } + + /** + * Returns true if python installed in the give SQL server instance + */ + public async isPythonInstalled(connection: azdata.connection.ConnectionProfile): Promise { + return this._queryRunner.isPythonInstalled(connection); + } + + /** + * Updates external script config + * @param connection SQL Connection + * @param enable if true external script will be enabled + */ + public async updateExternalScriptConfig(connection: azdata.connection.ConnectionProfile, enable: boolean): Promise { + await this._queryRunner.updateExternalScriptConfig(connection, enable); + let current = await this._queryRunner.isMachineLearningServiceEnabled(connection); + if (current === enable) { + this._apiWrapper.showInfoMessage(enable ? constants.mlsEnabledMessage : constants.mlsDisabledMessage); + } else { + this._apiWrapper.showErrorMessage(constants.mlsConfigUpdateFailed); + } + + return current; + } +} diff --git a/extensions/machine-learning-services/src/test/common/processService.test.ts b/extensions/machine-learning-services/src/test/common/processService.test.ts new file mode 100644 index 0000000000..3db68a5cab --- /dev/null +++ b/extensions/machine-learning-services/src/test/common/processService.test.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; +import { ProcessService } from '../../common/processService'; +import * as utils from '../../common/utils'; +import should = require('should'); + +interface TestContext { + + outputChannel: vscode.OutputChannel; +} + +function createContext(): TestContext { + return { + outputChannel: { + name: '', + append: () => { }, + appendLine: () => { }, + clear: () => { }, + show: () => { }, + hide: () => { }, + dispose: () => { } + } + }; +} + +function execFolderListCommand(context: TestContext, service : ProcessService): Promise { + if (utils.isWindows()) { + return service.execScripts('cmd', ['dir', '.'], context.outputChannel); + } else { + return service.execScripts('/bin/sh', ['-c', 'ls'], context.outputChannel); + } +} + +function execFolderListBufferedCommand(context: TestContext, service : ProcessService): Promise { + if (utils.isWindows()) { + return service.executeBufferedCommand('dir', context.outputChannel); + } else { + return service.executeBufferedCommand('ls', context.outputChannel); + } +} + +describe('Process Service', () => { + it('Executing a valid script should return successfully', async function (): Promise { + const context = createContext(); + let service = new ProcessService(); + await should(execFolderListCommand(context, service)).resolved(); + }); + + it('execFolderListCommand should reject if command time out @UNSTABLE@', async function (): Promise { + const context = createContext(); + let service = new ProcessService(); + service.timeout = 10; + await should(execFolderListCommand(context, service)).rejected(); + }); + + it('executeBufferedCommand should resolve give valid script', async function (): Promise { + const context = createContext(); + let service = new ProcessService(); + service.timeout = 2000; + await should(execFolderListBufferedCommand(context, service)).resolved(); + }); + +}); diff --git a/extensions/machine-learning-services/src/test/mainController.test.ts b/extensions/machine-learning-services/src/test/mainController.test.ts index f93f9b0e17..6c3f454b3b 100644 --- a/extensions/machine-learning-services/src/test/mainController.test.ts +++ b/extensions/machine-learning-services/src/test/mainController.test.ts @@ -72,8 +72,7 @@ function createContext(): TestContext { } function createController(testContext: TestContext): MainController { - let controller = new MainController(testContext.context, testContext.apiWrapper.object, testContext.queryRunner.object, testContext.processService.object); - controller.packageManager = testContext.packageManager.object; + let controller = new MainController(testContext.context, testContext.apiWrapper.object, testContext.queryRunner.object, testContext.processService.object, testContext.packageManager.object); return controller; } diff --git a/extensions/machine-learning-services/src/test/serverConfig/serverConfigManager.test.ts b/extensions/machine-learning-services/src/test/serverConfig/serverConfigManager.test.ts new file mode 100644 index 0000000000..7d97e92096 --- /dev/null +++ b/extensions/machine-learning-services/src/test/serverConfig/serverConfigManager.test.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as azdata from 'azdata'; +import { QueryRunner } from '../../common/queryRunner'; +import { ApiWrapper } from '../../common/apiWrapper'; +import * as TypeMoq from 'typemoq'; +import * as should from 'should'; +import { ServerConfigManager } from '../../serverConfig/serverConfigManager'; + +interface TestContext { + + apiWrapper: TypeMoq.IMock; + queryRunner: TypeMoq.IMock; +} + +function createContext(): TestContext { + return { + apiWrapper: TypeMoq.Mock.ofType(ApiWrapper), + queryRunner: TypeMoq.Mock.ofType(QueryRunner) + }; +} + +describe('Server Config Manager', () => { + it('openDocuments should open document in browser successfully', async function (): Promise { + const context = createContext(); + context.apiWrapper.setup(x => x.openExternal(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + should.equal(await serverConfigManager.openDocuments(), true); + }); + + it('openOdbcDriverDocuments should open document in browser successfully', async function (): Promise { + const context = createContext(); + context.apiWrapper.setup(x => x.openExternal(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + should.equal(await serverConfigManager.openOdbcDriverDocuments(), true); + }); + + it('openInstallDocuments should open document in browser successfully', async function (): Promise { + const context = createContext(); + context.apiWrapper.setup(x => x.openExternal(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + should.equal(await serverConfigManager.openInstallDocuments(), true); + }); + + it('isMachineLearningServiceEnabled should return true if external script is enabled', async function (): Promise { + const context = createContext(); + context.queryRunner.setup(x => x.isMachineLearningServiceEnabled(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + let connection = new azdata.connection.ConnectionProfile(); + should.equal(await serverConfigManager.isMachineLearningServiceEnabled(connection), true); + }); + + it('isRInstalled should return true if R is installed', async function (): Promise { + const context = createContext(); + context.queryRunner.setup(x => x.isRInstalled(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + let connection = new azdata.connection.ConnectionProfile(); + should.equal(await serverConfigManager.isRInstalled(connection), true); + }); + + it('isPythonInstalled should return true if Python is installed', async function (): Promise { + const context = createContext(); + context.queryRunner.setup(x => x.isPythonInstalled(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + let connection = new azdata.connection.ConnectionProfile(); + should.equal(await serverConfigManager.isPythonInstalled(connection), true); + }); + + it('updateExternalScriptConfig should show info message if updated successfully', async function (): Promise { + const context = createContext(); + context.queryRunner.setup(x => x.updateExternalScriptConfig(TypeMoq.It.isAny(), true)).returns(() => Promise.resolve()); + context.queryRunner.setup(x => x.isMachineLearningServiceEnabled(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + context.apiWrapper.setup(x => x.showInfoMessage(TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + context.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + let connection = new azdata.connection.ConnectionProfile(); + await serverConfigManager.updateExternalScriptConfig(connection, true); + + context.apiWrapper.verify(x => x.showInfoMessage(TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); + + it('updateExternalScriptConfig should show error message if did not updated successfully', async function (): Promise { + const context = createContext(); + context.queryRunner.setup(x => x.updateExternalScriptConfig(TypeMoq.It.isAny(), true)).returns(() => Promise.resolve()); + context.queryRunner.setup(x => x.isMachineLearningServiceEnabled(TypeMoq.It.isAny())).returns(() => Promise.resolve(false)); + context.apiWrapper.setup(x => x.showInfoMessage(TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + context.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + let serverConfigManager = new ServerConfigManager(context.apiWrapper.object, context.queryRunner.object); + let connection = new azdata.connection.ConnectionProfile(); + await serverConfigManager.updateExternalScriptConfig(connection, true); + + context.apiWrapper.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); +}); diff --git a/extensions/machine-learning-services/src/widgets/configTable.ts b/extensions/machine-learning-services/src/widgets/configTable.ts new file mode 100644 index 0000000000..771c67ecbd --- /dev/null +++ b/extensions/machine-learning-services/src/widgets/configTable.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ApiWrapper } from '../common/apiWrapper'; +import { ServerConfigManager } from '../serverConfig/serverConfigManager'; +import * as constants from '../common/constants'; + +export class ConfigTable { + private _statusTable: azdata.DeclarativeTableComponent; + + constructor(private _apiWrapper: ApiWrapper, private _serverConfigManager: ServerConfigManager, private _modelBuilder: azdata.ModelBuilder, private _loadingComponent: azdata.LoadingComponent) { + this._statusTable = this._modelBuilder.declarativeTable() + .withProperties( + { + columns: [ + { // Config + displayName: constants.mlsConfigTitle, + valueType: azdata.DeclarativeDataType.component, + isReadOnly: true, + width: 175, + headerCssStyles: { + ...constants.cssStyles.tableHeader + }, + rowCssStyles: { + ...constants.cssStyles.tableRow + }, + }, + { // Status icon + displayName: constants.mlsConfigStatus, + ariaLabel: constants.mlsConfigStatus, + valueType: azdata.DeclarativeDataType.component, + isReadOnly: true, + width: 25, + headerCssStyles: { + ...constants.cssStyles.tableHeader + }, + rowCssStyles: { + ...constants.cssStyles.tableRow + }, + }, + { // Action + displayName: '', + valueType: azdata.DeclarativeDataType.component, + isReadOnly: true, + width: 150, + headerCssStyles: { + ...constants.cssStyles.tableHeader + }, + rowCssStyles: { + ...constants.cssStyles.tableRow + }, + } + ], + data: [], + ariaLabel: constants.mlsConfigTitle + }) + .component(); + } + + /** + * Returns the config table component + */ + public get component(): azdata.DeclarativeTableComponent { + return this._statusTable; + } + + /** + * Refreshes the config table + */ + public async refresh(): Promise { + this._loadingComponent.updateProperties({ loading: true }); + let connection = await this.getCurrentConnection(); + const externalScriptsConfig = await this.createTableRowComponents(constants.mlsExternalExecuteScriptTitle, + async () => { + return await this._serverConfigManager.isMachineLearningServiceEnabled(connection); + }, async (enable) => { + this._loadingComponent.updateProperties({ loading: true }); + await this._serverConfigManager.updateExternalScriptConfig(connection, enable); + await this.refresh(); + } + ); + const pythonConfig = await this.createTableRowComponents(constants.mlsPythonLanguageTitle, + async () => { + return await this._serverConfigManager.isPythonInstalled(connection); + }, async () => { + await this._serverConfigManager.openInstallDocuments(); + } + ); + const rConfig = await this.createTableRowComponents(constants.mlsRLanguageTitle, + async () => { + return await this._serverConfigManager.isRInstalled(connection); + }, async () => { + await this._serverConfigManager.openInstallDocuments(); + } + ); + this._statusTable.data = [externalScriptsConfig, pythonConfig, rConfig]; + this._loadingComponent.updateProperties({ loading: false }); + } + + private async getCurrentConnection(): Promise { + return await this._apiWrapper.getCurrentConnection(); + } + + private async createTableRowComponents(configName: string, checkEnabledFunction: () => Promise, updateFunction: (enable: boolean) => Promise): Promise { + const isEnabled = await checkEnabledFunction(); + + const nameCell = this._modelBuilder.text() + .withProperties({ + value: configName, + CSSStyles: { 'user-select': 'none', ...constants.cssStyles.text } + }).component(); + const statusIconCell = this._modelBuilder.text() + .withProperties({ + value: this.getConfigStatusIcon(isEnabled), + ariaRole: 'img', + title: this.getConfigStatusTest(isEnabled), + CSSStyles: { 'user-select': 'none', ...constants.cssStyles.text } + }).component(); + + const button = this._modelBuilder.button().withProperties({ + label: '', + title: '' + }).component(); + + button.label = this.getLabel(isEnabled); + button.onDidClick(async () => { + await updateFunction(!isEnabled); + const isEnabledNewValue = await checkEnabledFunction(); + button.label = this.getLabel(isEnabledNewValue); + }); + return [ + nameCell, + statusIconCell, + button + ]; + } + + private getConfigStatusIcon(enabled: boolean): string { + if (enabled) { + return '✔️'; + } else { + return '❌'; + } + } + + private getConfigStatusTest(enabled: boolean): string { + if (enabled) { + return constants.mlsEnableButtonTitle; + } else { + return constants.mlsDisableButtonTitle; + } + } + + private getLabel(isEnabled: boolean): string { + return isEnabled ? constants.mlsDisableButtonTitle : constants.mlsEnableButtonTitle; + } +} diff --git a/extensions/machine-learning-services/src/widgets/serverConfigWidgets.ts b/extensions/machine-learning-services/src/widgets/serverConfigWidgets.ts new file mode 100644 index 0000000000..ab5b64c4db --- /dev/null +++ b/extensions/machine-learning-services/src/widgets/serverConfigWidgets.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ApiWrapper } from '../common/apiWrapper'; +import { ServerConfigManager } from '../serverConfig/serverConfigManager'; +import { ConfigTable } from './configTable'; + +export class ServerConfigWidget { + + constructor(private _apiWrapper: ApiWrapper, private _serverConfigManager: ServerConfigManager) { + } + + /** + * Registers the widget and initializes the components + */ + public register(): void { + azdata.ui.registerModelViewProvider('ml.tasks', async (view) => { + const container = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column', + width: '100%', + height: '100%' + }).component(); + const mainContainer = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'column', + width: '270px', + height: '100%', + position: 'absolute' + }).component(); + mainContainer.addItem(container, { + CSSStyles: { + 'padding-top': '25px', + 'padding-left': '5px' + } + }); + let spinner = view.modelBuilder.loadingComponent() + .withItem(mainContainer) + .withProperties({ loading: true }) + .component(); + + const configTable = new ConfigTable(this._apiWrapper, this._serverConfigManager, view.modelBuilder, spinner); + + this.addRow(container, view, configTable.component); + + await view.initializeModel(spinner); + await configTable.refresh(); + }); + } + + private addRow(container: azdata.FlexContainer, view: azdata.ModelView, component: azdata.Component) { + const bookRow = view.modelBuilder.flexContainer().withLayout({ + flexFlow: 'row', + justifyContent: 'space-between', + height: '100' + }).component(); + bookRow.addItem(component, { + CSSStyles: { + 'width': '100', + 'hight': '100', + 'padding-top': '10px', + 'text-align': 'left' + } + }); + container.addItems([bookRow]); + } +} + diff --git a/extensions/machine-learning-services/yarn.lock b/extensions/machine-learning-services/yarn.lock index bbfa2ce27a..971a87df0f 100644 --- a/extensions/machine-learning-services/yarn.lock +++ b/extensions/machine-learning-services/yarn.lock @@ -7,23 +7,11 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.6.tgz#b8622d50557dd155e9f2f634b7d68fd38de5e94b" integrity sha512-1axi39YdtBI7z957vdqXI4Ac25e7YihYQtJa+Clnxg1zTJEaIRbndt71O3sP4GAMgiAm0pY26/b9BrY4MR/PMw== -"@types/node@*": - version "12.12.17" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.17.tgz#191b71e7f4c325ee0fb23bc4a996477d92b8c39b" - integrity sha512-Is+l3mcHvs47sKy+afn2O1rV4ldZFU7W8101cNlOd+MRbjM4Onida8jSZnJdTe/0Pcf25g9BNIUsuugmE6puHA== - "@types/node@^10.14.8": version "10.14.17" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.17.tgz#b96d4dd3e427382482848948041d3754d40fd5ce" integrity sha512-p/sGgiPaathCfOtqu2fx5Mu1bcjuP8ALFg4xpGgNkcin7LwRyzUKniEHBKdcE1RPsenq5JVPIpMTJSygLboygQ== -"@types/uuid@^3.4.5": - version "3.4.6" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.6.tgz#d2c4c48eb85a757bf2927f75f939942d521e3016" - integrity sha512-cCdlC/1kGEZdEglzOieLDYBxHsvEOIg7kp/2FYyVR9Pxakq+Qf/inL3RKQ+PA8gOlI/NnL+fXmQH12nwcGzsHw== - dependencies: - "@types/node" "*" - ajv@^6.5.5: version "6.10.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" @@ -1230,6 +1218,11 @@ semver@^5.4.1: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== +semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + should-equal@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" @@ -1593,6 +1586,32 @@ vinyl@^2.0.0, vinyl@^2.0.1, vinyl@^2.0.2: remove-trailing-separator "^1.0.1" replace-ext "^1.0.0" +vscode-jsonrpc@^5.0.0-next.5: + version "5.0.0-next.5" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-5.0.0-next.5.tgz#43284da590b86320e427c3256bbe6849d8c6a6bd" + integrity sha512-k9akfglxWgr0dtLNscq2uBq48XJwnhf4EaDxn05KQowRwR0DkNML0zeYqFRLtXZe6x5vpL5ppyu4o6GqL+23YQ== + +vscode-languageclient@^5.3.0-next.1: + version "5.3.0-next.9" + resolved "https://registry.yarnpkg.com/vscode-languageclient/-/vscode-languageclient-5.3.0-next.9.tgz#34f58017647f15cd86015f7af45935dc750611f7" + integrity sha512-BFA3X1y2EI2CfsSBy0KG2Xr5BOYfd/97jTmD+doqL6oj+cY8S7AmRCOwb2f9Hbjq8GWL7YC+OJ0leZEUSPgP0A== + dependencies: + semver "^6.3.0" + vscode-languageserver-protocol "^3.15.0-next.8" + +vscode-languageserver-protocol@^3.15.0-next.8: + version "3.15.0-next.14" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.15.0-next.14.tgz#e7eb337f1adb50b4a41c05d436ce03c8df1f4d14" + integrity sha512-xUwwno6Q6RFd2Z2EWV9D3dQlsKPnHyiZMNWq+EC7JJdp2WH1gRlD+KPX4UGRCnJK0WI5omqHV313IESPwRY5xA== + dependencies: + vscode-jsonrpc "^5.0.0-next.5" + vscode-languageserver-types "^3.15.0-next.9" + +vscode-languageserver-types@^3.15.0-next.9: + version "3.15.0-next.9" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.15.0-next.9.tgz#957a9d1d5998a02edf62298fb7e37d9efcc6c157" + integrity sha512-Rl/8qJ6932nrHCdPn+9y0x08uLVQaSLRG+U4JzhyKpWU4eJbVaDRoAcz1Llj7CErJGbPr6kdBvShPy5fRfR+Uw== + vscode-nls@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.1.tgz#f9916b64e4947b20322defb1e676a495861f133c"