From 8288360cc46910025453c14e5e02cda8d1ac33d0 Mon Sep 17 00:00:00 2001 From: Vladimir Chernov Date: Fri, 29 May 2020 01:17:41 +0300 Subject: [PATCH] Assessment core extension (#10154) --- build/lib/extensions.js | 3 +- build/lib/extensions.ts | 3 +- extensions/mssql/src/contracts.ts | 11 +- extensions/mssql/src/features.ts | 71 +- extensions/mssql/src/mssql.d.ts | 36 +- .../src/sqlAssessment/sqlAssessmentService.ts | 6 +- extensions/mssql/src/sqlToolsServer.ts | 3 +- .../sql-assessment/images/extension.png | Bin 0 -> 3338 bytes extensions/sql-assessment/package.json | 43 ++ extensions/sql-assessment/package.nls.json | 5 + .../resources/dark/notebook_inverse.svg | 1 + .../resources/dark/open_notebook_inverse.svg | 1 + .../resources/light/notebook.svg | 1 + .../resources/light/open_notebook.svg | 1 + extensions/sql-assessment/src/main.ts | 13 + .../sql-assessment/src/typings/ref.d.ts | 9 + extensions/sql-assessment/tsconfig.json | 14 + extensions/sql-assessment/yarn.lock | 8 + src/sql/azdata.d.ts | 6 +- src/sql/azdata.proposed.d.ts | 43 ++ .../opener/common/openerServiceStub.ts | 17 + .../telemetry/common/telemetryKeys.ts | 4 +- .../api/browser/mainThreadDataProtocol.ts | 21 +- .../api/common/extHostDataProtocol.ts | 19 +- .../api/common/sqlExtHost.api.impl.ts | 13 +- .../api/common/sqlExtHost.protocol.ts | 21 + .../workbench/api/common/sqlExtHostTypes.ts | 7 +- .../browser/asmtResultsView.component.html | 2 + .../browser/asmtResultsView.component.ts | 627 ++++++++++++++++++ .../browser/asmtView.component.html | 57 ++ .../assessment/browser/asmtView.component.ts | 79 +++ .../contrib/assessment/browser/media/asmt.css | 132 ++++ .../browser/media/configuredashboard.svg | 3 + .../media/configuredashboard_inverse.svg | 3 + .../assessment/browser/media/detailview.css | 38 ++ .../assessment/browser/media/newquery.svg | 1 + .../browser/media/newquery_inverse.svg | 1 + .../contrib/assessment/common/asmtActions.ts | 227 +++++++ .../contrib/assessment/common/consts.ts | 22 + .../test/common/asmtActions.test.ts | 184 +++++ .../controlHostContent.component.html | 1 + .../contents/controlHostContent.component.ts | 9 +- .../dashboard/browser/dashboard.module.ts | 4 +- .../assessment/common/assessmentService.ts | 60 ++ .../services/assessment/common/interfaces.ts | 19 + .../assessment/test/assessmentService.test.ts | 19 + src/vs/workbench/workbench.common.main.ts | 3 + 47 files changed, 1813 insertions(+), 58 deletions(-) create mode 100644 extensions/sql-assessment/images/extension.png create mode 100644 extensions/sql-assessment/package.json create mode 100644 extensions/sql-assessment/package.nls.json create mode 100644 extensions/sql-assessment/resources/dark/notebook_inverse.svg create mode 100644 extensions/sql-assessment/resources/dark/open_notebook_inverse.svg create mode 100644 extensions/sql-assessment/resources/light/notebook.svg create mode 100644 extensions/sql-assessment/resources/light/open_notebook.svg create mode 100644 extensions/sql-assessment/src/main.ts create mode 100644 extensions/sql-assessment/src/typings/ref.d.ts create mode 100644 extensions/sql-assessment/tsconfig.json create mode 100644 extensions/sql-assessment/yarn.lock create mode 100644 src/sql/platform/opener/common/openerServiceStub.ts create mode 100644 src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.html create mode 100644 src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts create mode 100644 src/sql/workbench/contrib/assessment/browser/asmtView.component.html create mode 100644 src/sql/workbench/contrib/assessment/browser/asmtView.component.ts create mode 100644 src/sql/workbench/contrib/assessment/browser/media/asmt.css create mode 100644 src/sql/workbench/contrib/assessment/browser/media/configuredashboard.svg create mode 100644 src/sql/workbench/contrib/assessment/browser/media/configuredashboard_inverse.svg create mode 100644 src/sql/workbench/contrib/assessment/browser/media/detailview.css create mode 100644 src/sql/workbench/contrib/assessment/browser/media/newquery.svg create mode 100644 src/sql/workbench/contrib/assessment/browser/media/newquery_inverse.svg create mode 100644 src/sql/workbench/contrib/assessment/common/asmtActions.ts create mode 100644 src/sql/workbench/contrib/assessment/common/consts.ts create mode 100644 src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts create mode 100644 src/sql/workbench/services/assessment/common/assessmentService.ts create mode 100644 src/sql/workbench/services/assessment/common/interfaces.ts create mode 100644 src/sql/workbench/services/assessment/test/assessmentService.test.ts diff --git a/build/lib/extensions.js b/build/lib/extensions.js index 770876a169..58dabba99c 100644 --- a/build/lib/extensions.js +++ b/build/lib/extensions.js @@ -208,7 +208,8 @@ const externalExtensions = [ 'query-history', 'liveshare', 'sql-database-projects', - 'machine-learning' + 'machine-learning', + 'sql-assessment' ]; // extensions that require a rebuild since they have native parts const rebuildExtensions = [ diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 0d78b7ad2c..63fae6bd86 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -243,7 +243,8 @@ const externalExtensions = [ 'query-history', 'liveshare', 'sql-database-projects', - 'machine-learning' + 'machine-learning', + 'sql-assessment' ]; // extensions that require a rebuild since they have native parts diff --git a/extensions/mssql/src/contracts.ts b/extensions/mssql/src/contracts.ts index a16087e95f..798a7a05e1 100644 --- a/extensions/mssql/src/contracts.ts +++ b/extensions/mssql/src/contracts.ts @@ -678,26 +678,26 @@ export namespace SchemaCompareCancellationRequest { // ------------------------------- ----------------------------- -// ------------------------------- ----------------------------- +/// ------------------------------- ----------------------------- export interface SqlAssessmentParams { ownerUri: string; - targetType: mssql.SqlAssessmentTargetType + targetType: azdata.sqlAssessment.SqlAssessmentTargetType } export interface GenerateSqlAssessmentScriptParams { - items: mssql.SqlAssessmentResultItem[]; + items: azdata.SqlAssessmentResultItem[]; taskExecutionMode: azdata.TaskExecutionMode; targetServerName: string; targetDatabaseName: string; } export namespace SqlAssessmentInvokeRequest { - export const type = new RequestType('assessment/invoke'); + export const type = new RequestType('assessment/invoke'); } export namespace GetSqlAssessmentItemsRequest { - export const type = new RequestType('assessment/getAssessmentItems'); + export const type = new RequestType('assessment/getAssessmentItems'); } export namespace GenerateSqlAssessmentScriptRequest { @@ -706,7 +706,6 @@ export namespace GenerateSqlAssessmentScriptRequest { // ------------------------------- ----------------------------- - // ------------------------------- ----------------------------- export namespace SerializeDataStartRequest { export const type = new RequestType('serialize/start'); diff --git a/extensions/mssql/src/features.ts b/extensions/mssql/src/features.ts index 5091480125..6582491227 100644 --- a/extensions/mssql/src/features.ts +++ b/extensions/mssql/src/features.ts @@ -746,7 +746,7 @@ export class AgentServicesFeature extends SqlOpsFeature { } ); }; - + // Job management methods return azdata.dataprotocol.registerAgentServicesProvider({ providerId: client.providerId, getJobs, @@ -852,3 +852,72 @@ export class SerializationFeature extends SqlOpsFeature { }); } } + +export class SqlAssessmentServicesFeature extends SqlOpsFeature { + private static readonly messagesTypes: RPCMessageType[] = [ + contracts.SqlAssessmentInvokeRequest.type, + contracts.GetSqlAssessmentItemsRequest.type + ]; + constructor(client: SqlOpsDataClient) { + super(client, SqlAssessmentServicesFeature.messagesTypes); + } + + public fillClientCapabilities(capabilities: ClientCapabilities): void { + } + + public initialize(capabilities: ServerCapabilities): void { + this.register(this.messages, { + id: UUID.generateUuid(), + registerOptions: undefined + }); + } + + protected registerProvider(options: undefined): Disposable { + const client = this._client; + + let assessmentInvoke = async (ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise => { + let params: contracts.SqlAssessmentParams = { ownerUri: ownerUri, targetType: targetType }; + try { + return client.sendRequest(contracts.SqlAssessmentInvokeRequest.type, params); + } + catch (e) { + client.logFailedRequest(contracts.SqlAssessmentInvokeRequest.type, e); + } + + return undefined; + }; + + let getAssessmentItems = async (ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise => { + let params: contracts.SqlAssessmentParams = { ownerUri: ownerUri, targetType: targetType }; + try { + return client.sendRequest(contracts.GetSqlAssessmentItemsRequest.type, params); + } + catch (e) { + client.logFailedRequest(contracts.GetSqlAssessmentItemsRequest.type, e); + } + + return undefined; + }; + + let generateAssessmentScript = async (items: azdata.SqlAssessmentResultItem[]): Promise => { + let params: contracts.GenerateSqlAssessmentScriptParams = { items: items, taskExecutionMode: azdata.TaskExecutionMode.script, targetServerName: '', targetDatabaseName: '' }; + try { + return client.sendRequest(contracts.GenerateSqlAssessmentScriptRequest.type, params); + } + catch (e) { + client.logFailedRequest(contracts.GenerateSqlAssessmentScriptRequest.type, e); + } + + return undefined; + }; + + return azdata.dataprotocol.registerSqlAssessmentServicesProvider({ + providerId: client.providerId, + assessmentInvoke, + getAssessmentItems, + generateAssessmentScript + }); + } + + +} diff --git a/extensions/mssql/src/mssql.d.ts b/extensions/mssql/src/mssql.d.ts index 798e71fe27..7767b2b2ad 100644 --- a/extensions/mssql/src/mssql.d.ts +++ b/extensions/mssql/src/mssql.d.ts @@ -483,40 +483,10 @@ export interface ListRegisteredServersResult { // SqlAssessment interfaces ----------------------------------------------------------------------- -export const enum SqlAssessmentTargetType { - Server = 1, - Database = 2 -} -export const enum SqlAssessmentResultItemKind { - RealResult = 0, - Warning = 1, - Error = 2 -} - -export interface SqlAssessmentResultItem { - rulesetVersion: string; - rulesetName: string; - targetType: SqlAssessmentTargetType; - targetName: string; - checkId: string; - tags: string[]; - displayName: string; - description: string; - message: string; - helpLink: string; - level: string; - timestamp: string; - kind: SqlAssessmentResultItemKind; -} - -export interface SqlAssessmentResult extends azdata.ResultStatus { - items: SqlAssessmentResultItem[]; - apiVersion: string; -} export interface ISqlAssessmentService { - assessmentInvoke(ownerUri: string, targetType: SqlAssessmentTargetType): Promise; - getAssessmentItems(ownerUri: string, targetType: SqlAssessmentTargetType): Promise; - generateAssessmentScript(items: SqlAssessmentResultItem[], targetServerName: string, targetDatabaseName: string, taskExecutionMode: azdata.TaskExecutionMode): Promise; + assessmentInvoke(ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise; + getAssessmentItems(ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise; + generateAssessmentScript(items: azdata.SqlAssessmentResultItem[], targetServerName: string, targetDatabaseName: string, taskExecutionMode: azdata.TaskExecutionMode): Promise; } diff --git a/extensions/mssql/src/sqlAssessment/sqlAssessmentService.ts b/extensions/mssql/src/sqlAssessment/sqlAssessmentService.ts index 6bc22fb492..f4e30cd551 100644 --- a/extensions/mssql/src/sqlAssessment/sqlAssessmentService.ts +++ b/extensions/mssql/src/sqlAssessment/sqlAssessmentService.ts @@ -30,7 +30,7 @@ export class SqlAssessmentService implements mssql.ISqlAssessmentService { private constructor(context: AppContext, protected readonly client: SqlOpsDataClient) { context.registerService(constants.SqlAssessmentService, this); } - async assessmentInvoke(ownerUri: string, targetType: mssql.SqlAssessmentTargetType): Promise { + async assessmentInvoke(ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise { let params: contracts.SqlAssessmentParams = { ownerUri: ownerUri, targetType: targetType }; try { return this.client.sendRequest(contracts.SqlAssessmentInvokeRequest.type, params); @@ -41,7 +41,7 @@ export class SqlAssessmentService implements mssql.ISqlAssessmentService { return undefined; } - async getAssessmentItems(ownerUri: string, targetType: mssql.SqlAssessmentTargetType): Promise { + async getAssessmentItems(ownerUri: string, targetType: azdata.sqlAssessment.SqlAssessmentTargetType): Promise { let params: contracts.SqlAssessmentParams = { ownerUri: ownerUri, targetType: targetType }; try { return this.client.sendRequest(contracts.GetSqlAssessmentItemsRequest.type, params); @@ -52,7 +52,7 @@ export class SqlAssessmentService implements mssql.ISqlAssessmentService { return undefined; } - async generateAssessmentScript(items: mssql.SqlAssessmentResultItem[], targetServerName: string, targetDatabaseName: string, taskExecutionMode: azdata.TaskExecutionMode): Promise { + async generateAssessmentScript(items: azdata.SqlAssessmentResultItem[], targetServerName: string, targetDatabaseName: string, taskExecutionMode: azdata.TaskExecutionMode): Promise { let params: contracts.GenerateSqlAssessmentScriptParams = { items: items, targetServerName: targetServerName, targetDatabaseName: targetDatabaseName, taskExecutionMode: taskExecutionMode }; try { return this.client.sendRequest(contracts.GenerateSqlAssessmentScriptRequest.type, params); diff --git a/extensions/mssql/src/sqlToolsServer.ts b/extensions/mssql/src/sqlToolsServer.ts index c0ed6444a8..cb03a7cb54 100644 --- a/extensions/mssql/src/sqlToolsServer.ts +++ b/extensions/mssql/src/sqlToolsServer.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import { getCommonLaunchArgsAndCleanupOldLogFiles } from './utils'; import { Telemetry, LanguageClientErrorHandler } from './telemetry'; import { SqlOpsDataClient, ClientOptions } from 'dataprotocol-client'; -import { TelemetryFeature, AgentServicesFeature, SerializationFeature, AccountFeature } from './features'; +import { TelemetryFeature, AgentServicesFeature, SerializationFeature, AccountFeature, SqlAssessmentServicesFeature } from './features'; import { CredentialStore } from './credentialstore/credentialstore'; import { AzureResourceProvider } from './resourceProvider/resourceProvider'; import { SchemaCompareService } from './schemaCompare/schemaCompareService'; @@ -155,6 +155,7 @@ function getClientOptions(context: AppContext): ClientOptions { AccountFeature, AgentServicesFeature, SerializationFeature, + SqlAssessmentServicesFeature, SchemaCompareService.asFeature(context), LanguageExtensionService.asFeature(context), DacFxService.asFeature(context), diff --git a/extensions/sql-assessment/images/extension.png b/extensions/sql-assessment/images/extension.png new file mode 100644 index 0000000000000000000000000000000000000000..c86d6d1e009f12f1ccb246db45303707052175e1 GIT binary patch literal 3338 zcmai%cQhRSvcSK)7OO>HqC`oEUQ$F^K_WpybkV}Hf~=?^HqmwwZV*IIh~7o7(N`Cu zMz^e|8Vs~tv;Y7GElqWUzr_7xaH_w) z{iaRdUr^cJ*H8y%|7dzsUMv9gEn4boM%d|%WI9j7U%cICnsg7=JTU`uUW-Z8%C{dX zUx-@MUQ5}lR+!{O?``K#Cxm5W+@nlXy7*1T~TFVEw=Xd3>7L7WeKdOIhYC8FY?v7Nx zrk9UZV>(^&RNT|A7NIU=q_i@bSDk5$@?Af?ZN!725(_?3kKe(2d3g~-DE*n^1E*hO z2+;kb^wx(({+rxwWfax)DrW@yO5#2C{h{stQ(0j*DKWqFNm6$`&i&d_OIoJ=-hCQl z@=W+Cldz-E=hoQOwL51I`1M|XwK%Sj%?*!a{iuZvDQ&f@dLd5uFyJC1e@4f(0ON5> z4cusZLarU6*rIblNs1YNd&M4UWh$KV>e7O^rdwTy%H9_FYzIv)KfZBX+sH77a2@66 z;W*kBWGYSyT1g6T=C5adQI>k9;);_T{c{JnI&b+DzRSM@?-J%w zh-+IzaT6TzFA7)1!*OQMcb#|`3C>407vm-4x|7ufVU&vQrchdU;yxBjekw3mqdqthMUE z_v_5-9{p(dmr*SAx}4rV5Syj=EV0n4jfaOolaFmz4zmXASUB}wtkwUM(5E?!o?D33E-fywvHJ_&YCypPRESJB!r-u2zw(3g3 zmZ_(RD}bSLBX0`RZ{M;qtusY324jz4;zHE8oYCevA|StE7W?D2lT+64yK<7}rx3NO z)r-GtQ84L=HzFCyXbnE(yeC&`iNZPExJgpEAS*J(g3++DaG}|GB{MfWv8^dNTpF|6 zeNX>(%ThP}1(oX!=1+raJ7<=i_ikdX=l1=CZW=Nn>+aVzRDE~ug}6lt`tT}F{Onae z{neHz)81}(Qt%VnIJ7H-G+qhTptB%xyo;B4vLrz6E%eJOY#3*6i<8U|b+;C+q};LzmNN43+uTr3>0w; z4k?WXk{@5-QPJdsO)cARr^uv%QHzW-~#XaRz6J`o~O36 z;F{(}YfCxtNX>g1xm53}bkR-=@cE3Ma6d_0p_y&_4e0jQ(q6U${%t*PKY2J++ zAr1$c2*7qMVV_vsCt#{kueIoA!O<^%t?lFqrbY5Nr{1-a%S!BHXi)K^i*ra@j+6r* z^r2_Pr!;&v1hsccp%O(zZ&WGtKdiI}NfV%!fJ%&1wUv4s5tJuLiYf*=f70Qpvt|s# zLdAMGS@^?-(SrLjI*~-Dl~2Fuz+@wRoxA_@D05e-o$&+F(`AX$v2#0*g|V`_jEpYB zt-2HJmV36(`0Au*^KJ~)&L)%;Ns;wLf7yRw*n9TU)KQqs4l>)N9SBv`8eZd4g!OQp z1mrL#mSL_`4u9-O(ce?_>hcx$-wC;az4ErRuO~=FsgpL`jl~7Y#i%*G9C3SC*YQZ)T9+;nMP#jG6K-Jv!x$KfWc8=ZIM1Euha>P9& z(2fczzAmg;R87x%7J0^}62jLP&>o;PeL5|xwPjNxtiG<;XGMfSAi(E3HCBf8qW7WH zj9FHVqVH8fa^<$7t5r>riL!vlmKmakd#{=mdJZ5$)Lw0cxHJ1mA#2TZq~*-XJQ)rj z5xjh9hxrV=LM;}Lo}%^+A>lop$Ln|;wJQA;+fvT$U%Mv`R#TSxB?Vg|?69?hR%zeE z-o-xyF+;kA)e{=LitScPK*B>M!VF9vBeek)-#f-7Yw2$R9>xVbS?ymwuRd1_2wK$! zY$tuf5sya~OjcN(s(vkHMebkyEr2TJgVm%1I8FfktCyr@rY9mSV(LzwMQ@x0J zkGHbf>r3XJ%5XXOPaCnP4qGOqIEh(V*y-myQH(pk!dn>OQIrpDDo%3Rb}hN~#`sfx z{-gNDK}(+IrsAjeFYk}Va&y1&Zh2g$tNrz`0?S)Q#-QAdTW!Vl*(v-I8;yQtT{M6!NXk{^Be$+=_N^wT>Ng9H2k4xs-P zxN-{B9x-Kzj0q&#q4r-MvdNH6QtI2yQBlK$j3KAchwb82i(YO6`3Gdmx*}6Ik>g9)9_$qlsFg?daSqYI@qTM4EjE zK(55SRV9fvhon-%pg<7z`em(vrr|pQM|V+W2Kq~YjJD%|ZMd+tL>y4S1c4MBBTc9F zo-3Ts;(<~i^gL)TxFnyk%FUErAj!m*m!phFLqKzbh`d@TlW7QBOW6cBe48|)<}qUemEeH>(<D80%(~f2@DV3qLWY*7c5YWh6#ow)zd@Ij+ zg!SLr{`(OBIeG{s;o~1i*SGZM9NQc9F;(CCfv*v`qJ|-)_e-&Rn2~ngyh)@a?J$-R z+YSUOEHm*nR5Pn5mg|*cE7X7XR59!jRUatL|8x2LI7m{ZWIW&$0;}1jas0qyjRj=E zTG#g#Mkr7w1Bc8wX*fuuaC`sC&x18K`^!{K9N3lxdo@kHmz;D(gNAwypsf#qZ!Y2O zb7X8)Q^g7bmL3DP6~T20&!5iy(h|rulLL=c0cfie;IXZ|>fdwx*}$OWHb}4qYj7Mh z1@2RyzhQsQyt(5z8;ryRA&G(pqog8gQxsL{g?^nC9x@0D2FX1syDCia3|e**M$(Xwtf3~ zrl2*|JYp9SOrosWc4UD@cV-&BNt(S$19dxhlCKd^F;r@otEbW}819k_{bL`O)PqRW z?o`+9&h9Sje9UbYA;&?dk7t@JlUefxtq&(d7-mK)$c4ytv~qY+gnhVpUPf+3TnLR3 zr>L1hr}I=1.18.0" + }, + "activationEvents": [ + "onDashboardOpen" + ], + "main": "./out/main", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/azuredatastudio.git" + }, + "extensionDependencies": [ + "Microsoft.mssql" + ], + "contributes": { + "dashboard.tabs": [{ + "id": "data-management-asmt", + "description": "%dashboard.tabName%", + "provider": "MSSQL", + "title": "%displayName%", + "when": "connectionProvider == 'MSSQL' && !mssql:iscloud && mssql:engineedition != 11", + "container": { + "controlhost-container": { + "type": "assessment" + } + } + }] + }, + "dependencies": { + "vscode-nls": "^3.2.1" + } +} diff --git a/extensions/sql-assessment/package.nls.json b/extensions/sql-assessment/package.nls.json new file mode 100644 index 0000000000..ead0f06b3d --- /dev/null +++ b/extensions/sql-assessment/package.nls.json @@ -0,0 +1,5 @@ +{ + "displayName": "SQL Server Assessment", + "description": "SQL Server Assessment for Azure Data Studio (Preview) provides a mechanism to evaluate the configuration of SQL Server for best practices.", + "dashboard.tabName": "SQL Assessment" +} diff --git a/extensions/sql-assessment/resources/dark/notebook_inverse.svg b/extensions/sql-assessment/resources/dark/notebook_inverse.svg new file mode 100644 index 0000000000..fb495dda69 --- /dev/null +++ b/extensions/sql-assessment/resources/dark/notebook_inverse.svg @@ -0,0 +1 @@ +notebook_inverse \ No newline at end of file diff --git a/extensions/sql-assessment/resources/dark/open_notebook_inverse.svg b/extensions/sql-assessment/resources/dark/open_notebook_inverse.svg new file mode 100644 index 0000000000..a95750c49f --- /dev/null +++ b/extensions/sql-assessment/resources/dark/open_notebook_inverse.svg @@ -0,0 +1 @@ +open_notebook_inverse \ No newline at end of file diff --git a/extensions/sql-assessment/resources/light/notebook.svg b/extensions/sql-assessment/resources/light/notebook.svg new file mode 100644 index 0000000000..dae58b840e --- /dev/null +++ b/extensions/sql-assessment/resources/light/notebook.svg @@ -0,0 +1 @@ +notebook \ No newline at end of file diff --git a/extensions/sql-assessment/resources/light/open_notebook.svg b/extensions/sql-assessment/resources/light/open_notebook.svg new file mode 100644 index 0000000000..0041ae9b21 --- /dev/null +++ b/extensions/sql-assessment/resources/light/open_notebook.svg @@ -0,0 +1 @@ +open_notebook \ No newline at end of file diff --git a/extensions/sql-assessment/src/main.ts b/extensions/sql-assessment/src/main.ts new file mode 100644 index 0000000000..f54d4572cd --- /dev/null +++ b/extensions/sql-assessment/src/main.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export function activate(_context: vscode.ExtensionContext) { +} + +// this method is called when your extension is deactivated +export function deactivate(): void { +} diff --git a/extensions/sql-assessment/src/typings/ref.d.ts b/extensions/sql-assessment/src/typings/ref.d.ts new file mode 100644 index 0000000000..4d46be908b --- /dev/null +++ b/extensions/sql-assessment/src/typings/ref.d.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/// +/// +/// +/// \ No newline at end of file diff --git a/extensions/sql-assessment/tsconfig.json b/extensions/sql-assessment/tsconfig.json new file mode 100644 index 0000000000..85a3e062d8 --- /dev/null +++ b/extensions/sql-assessment/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../shared.tsconfig.json", + "compileOnSave": true, + "compilerOptions": { + "outDir": "./out", + "lib": [ + "es6", "es2015.promise" + ], + "moduleResolution": "node" + }, + "exclude": [ + "node_modules" + ] +} diff --git a/extensions/sql-assessment/yarn.lock b/extensions/sql-assessment/yarn.lock new file mode 100644 index 0000000000..45f8b9278d --- /dev/null +++ b/extensions/sql-assessment/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +vscode-nls@^3.2.1: + version "3.2.5" + resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-3.2.5.tgz#25520c1955108036dec607c85e00a522f247f1a4" + integrity sha512-ITtoh3V4AkWXMmp3TB97vsMaHRgHhsSFPsUdzlueSL+dRZbSNTZeOmdQv60kjCV306ghPxhDeoNUEm3+EZMuyw== diff --git a/src/sql/azdata.d.ts b/src/sql/azdata.d.ts index 966b423cff..b0ffcaa608 100644 --- a/src/sql/azdata.d.ts +++ b/src/sql/azdata.d.ts @@ -1798,8 +1798,9 @@ declare module 'azdata' { deleteJobSchedule(ownerUri: string, scheduleInfo: AgentJobScheduleInfo): Thenable; registerOnUpdated(handler: () => any): void; - } + + } // DacFx interfaces ----------------------------------------------------------------------- // Security service interfaces ------------------------------------------------------------------------ @@ -4102,7 +4103,8 @@ declare module 'azdata' { CapabilitiesProvider = 'CapabilitiesProvider', ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider', IconProvider = 'IconProvider', - SerializationProvider = 'SerializationProvider' + SerializationProvider = 'SerializationProvider', + SqlAssessmentServicesProvider = 'SqlAssessmentServicesProvider' } /** diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 9781c2ba27..560f9da842 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -86,6 +86,7 @@ declare module 'azdata' { export namespace dataprotocol { export function registerSerializationProvider(provider: SerializationProvider): vscode.Disposable; + export function registerSqlAssessmentServicesProvider(provider: SqlAssessmentServicesProvider): vscode.Disposable; } export interface HyperlinkComponent { @@ -399,5 +400,47 @@ declare module 'azdata' { export interface TaskInfo { targetLocation?: string; } + + export namespace sqlAssessment { + + export enum SqlAssessmentTargetType { + Server = 1, + Database = 2 + } + + export enum SqlAssessmentResultItemKind { + RealResult = 0, + Warning = 1, + Error = 2 + } + } + // Assessment interfaces + + export interface SqlAssessmentResultItem { + rulesetVersion: string; + rulesetName: string; + targetType: sqlAssessment.SqlAssessmentTargetType; + targetName: string; + checkId: string; + tags: string[]; + displayName: string; + description: string; + message: string; + helpLink: string; + level: string; + timestamp: string; + kind: sqlAssessment.SqlAssessmentResultItemKind; + } + + export interface SqlAssessmentResult extends ResultStatus { + items: SqlAssessmentResultItem[]; + apiVersion: string; + } + + export interface SqlAssessmentServicesProvider extends DataProvider { + assessmentInvoke(ownerUri: string, targetType: sqlAssessment.SqlAssessmentTargetType): Promise; + getAssessmentItems(ownerUri: string, targetType: sqlAssessment.SqlAssessmentTargetType): Promise; + generateAssessmentScript(items: SqlAssessmentResultItem[]): Promise; + } } diff --git a/src/sql/platform/opener/common/openerServiceStub.ts b/src/sql/platform/opener/common/openerServiceStub.ts new file mode 100644 index 0000000000..eb93513f80 --- /dev/null +++ b/src/sql/platform/opener/common/openerServiceStub.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { URI } from 'vs/base/common/uri'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; + + +export class OpenerServiceStub implements IOpenerService { + _serviceBrand: undefined; + registerOpener() { return undefined; } + registerValidator() { return undefined; } + registerExternalUriResolver() { return undefined; } + setExternalOpener() { return undefined; } + async open(resource: URI | string, options?: any): Promise { return Promise.resolve(true); } + async resolveExternalUri(uri: any) { return undefined; } +} diff --git a/src/sql/platform/telemetry/common/telemetryKeys.ts b/src/sql/platform/telemetry/common/telemetryKeys.ts index 061f72cd13..66a36fba8a 100644 --- a/src/sql/platform/telemetry/common/telemetryKeys.ts +++ b/src/sql/platform/telemetry/common/telemetryKeys.ts @@ -64,10 +64,12 @@ export enum TelemetryView { Shell = 'Shell', ExtensionRecommendationDialog = 'ExtensionRecommendationDialog', ResultsPanel = 'ResultsPanel', - Notebook = 'Notebook' + Notebook = 'Notebook', + SqlAssessment = 'SqlAssessment' } export enum TelemetryAction { Click = 'Click', Open = 'Open' } + diff --git a/src/sql/workbench/api/browser/mainThreadDataProtocol.ts b/src/sql/workbench/api/browser/mainThreadDataProtocol.ts index 7464eaf2e8..ab23e9b849 100644 --- a/src/sql/workbench/api/browser/mainThreadDataProtocol.ts +++ b/src/sql/workbench/api/browser/mainThreadDataProtocol.ts @@ -27,6 +27,7 @@ import { IExtHostContext } from 'vs/workbench/api/common/extHost.protocol'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { assign } from 'vs/base/common/objects'; import { serializableToMap } from 'sql/base/common/map'; +import { IAssessmentService } from 'sql/workbench/services/assessment/common/interfaces'; /** * Main thread class for handling data protocol management registration. @@ -53,7 +54,8 @@ export class MainThreadDataProtocol extends Disposable implements MainThreadData @ITaskService private _taskService: ITaskService, @IProfilerService private _profilerService: IProfilerService, @ISerializationService private _serializationService: ISerializationService, - @IFileBrowserService private _fileBrowserService: IFileBrowserService + @IFileBrowserService private _fileBrowserService: IFileBrowserService, + @IAssessmentService private _assessmentService: IAssessmentService ) { super(); if (extHostContext) { @@ -447,6 +449,23 @@ export class MainThreadDataProtocol extends Disposable implements MainThreadData return undefined; } + public $registerSqlAssessmentServicesProvider(providerId: string, handle: number): Promise { + const self = this; + this._assessmentService.registerProvider(providerId, { + providerId: providerId, + assessmentInvoke(connectionUri: string, targetType: number): Thenable { + return self._proxy.$assessmentInvoke(handle, connectionUri, targetType); + }, + getAssessmentItems(connectionUri: string, targetType: number): Thenable { + return self._proxy.$getAssessmentItems(handle, connectionUri, targetType); + }, + generateAssessmentScript(items: azdata.SqlAssessmentResultItem[]): Thenable { + return self._proxy.$generateAssessmentScript(handle, items); + } + }); + + return undefined; + } public $registerCapabilitiesServiceProvider(providerId: string, handle: number): Promise { const self = this; this._capabilitiesService.registerProvider({ diff --git a/src/sql/workbench/api/common/extHostDataProtocol.ts b/src/sql/workbench/api/common/extHostDataProtocol.ts index 360fe9ecc8..cb70e90037 100644 --- a/src/sql/workbench/api/common/extHostDataProtocol.ts +++ b/src/sql/workbench/api/common/extHostDataProtocol.ts @@ -168,7 +168,11 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { this._proxy.$registerAgentServicesProvider(provider.providerId, provider.handle); return rt; } - + $registerSqlAssessmentServiceProvider(provider: azdata.SqlAssessmentServicesProvider): vscode.Disposable { + let rt = this.registerProvider(provider, DataProviderType.SqlAssessmentServicesProvider); + this._proxy.$registerSqlAssessmentServicesProvider(provider.providerId, provider.handle); + return rt; + } $registerCapabilitiesServiceProvider(provider: azdata.CapabilitiesProvider): vscode.Disposable { let rt = this.registerProvider(provider, DataProviderType.CapabilitiesProvider); this._proxy.$registerCapabilitiesServiceProvider(provider.providerId, provider.handle); @@ -839,4 +843,17 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { public $continueSerialization(handle: number, requestParams: azdata.SerializeDataContinueRequestParams): Thenable { return this._resolveProvider(handle).continueSerialization(requestParams); } + + // Assessment methods + public $assessmentInvoke(handle: number, ownerUri: string, targetType: number): Thenable { + return this._resolveProvider(handle).assessmentInvoke(ownerUri, targetType); + } + + public $getAssessmentItems(handle: number, ownerUri: string, targetType: number): Thenable { + return this._resolveProvider(handle).getAssessmentItems(ownerUri, targetType); + } + + public $generateAssessmentScript(handle: number, items: azdata.SqlAssessmentResultItem[]): Thenable { + return this._resolveProvider(handle).generateAssessmentScript(items); + } } diff --git a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts index f24db7e28c..10cb67a552 100644 --- a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts @@ -364,6 +364,10 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp return extHostDataProvider.$registerSerializationProvider(provider); }; + let registerSqlAssessmentServicesProvider = (provider: azdata.SqlAssessmentServicesProvider): vscode.Disposable => { + return extHostDataProvider.$registerSqlAssessmentServiceProvider(provider); + }; + // namespace: dataprotocol const dataprotocol: typeof azdata.dataprotocol = { registerBackupProvider, @@ -382,6 +386,7 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp registerAgentServicesProvider, registerCapabilitiesServiceProvider, registerSerializationProvider, + registerSqlAssessmentServicesProvider, onDidChangeLanguageFlavor(listener: (e: azdata.DidChangeLanguageFlavorParams) => any, thisArgs?: any, disposables?: extHostTypes.Disposable[]) { return extHostDataProvider.onDidChangeLanguageFlavor(listener, thisArgs, disposables); }, @@ -510,6 +515,11 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp NotebookChangeKind: sqlExtHostTypes.NotebookChangeKind }; + const sqlAssessment: typeof azdata.sqlAssessment = { + SqlAssessmentResultItemKind: sqlExtHostTypes.SqlAssessmentResultItemKind, + SqlAssessmentTargetType: sqlExtHostTypes.SqlAssessmentTargetType + }; + return { accounts, connection, @@ -556,7 +566,8 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp ExtensionNodeType: sqlExtHostTypes.ExtensionNodeType, ColumnSizingMode: sqlExtHostTypes.ColumnSizingMode, DatabaseEngineEdition: sqlExtHostTypes.DatabaseEngineEdition, - TabOrientation: sqlExtHostTypes.TabOrientation + TabOrientation: sqlExtHostTypes.TabOrientation, + sqlAssessment }; } }; diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index 98a75306b8..f05199857a 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -488,6 +488,26 @@ export abstract class ExtHostDataProtocolShape { * Serialization continuation request */ $continueSerialization(handle: number, requestParams: azdata.SerializeDataContinueRequestParams): Thenable { throw ni(); } + + + /** + * SQL Assessment Section + */ + + /** + * Perform an assessment + */ + $assessmentInvoke(handle: number, connectionUri: string, targetType: number): Thenable { throw ni(); } + + /** + * Get applicable assessment rules + */ + $getAssessmentItems(handle: number, connectionUri: string, targetType: number): Thenable { throw ni(); } + + /** + * Generate an assessment script based on recent results + */ + $generateAssessmentScript(handle: number, items: azdata.SqlAssessmentResultItem[]): Thenable { throw ni(); } } /** @@ -551,6 +571,7 @@ export interface MainThreadDataProtocolShape extends IDisposable { $registerAdminServicesProvider(providerId: string, handle: number): Promise; $registerAgentServicesProvider(providerId: string, handle: number): Promise; $registerSerializationProvider(providerId: string, handle: number): Promise; + $registerSqlAssessmentServicesProvider(providerId: string, handle: number): Promise; $unregisterProvider(handle: number): Promise; $onConnectionComplete(handle: number, connectionInfoSummary: azdata.ConnectionInfoSummary): void; $onIntelliSenseCacheComplete(handle: number, connectionUri: string): void; diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index 2a56dc4a62..3d2daaf9bc 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -350,7 +350,8 @@ export enum DataProviderType { CapabilitiesProvider = 'CapabilitiesProvider', ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider', SerializationProvider = 'SerializationProvider', - IconProvider = 'IconProvider' + IconProvider = 'IconProvider', + SqlAssessmentServicesProvider = 'SqlAssessmentServicesProvider' } export enum DeclarativeDataType { @@ -847,12 +848,12 @@ export interface TabbedPanelLayout { alwaysShowTabs: boolean; } -export const enum SqlAssessmentTargetType { +export enum SqlAssessmentTargetType { Server = 1, Database = 2 } -export const enum SqlAssessmentResultItemKind { +export enum SqlAssessmentResultItemKind { RealResult = 0, Warning = 1, Error = 2 diff --git a/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.html b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.html new file mode 100644 index 0000000000..f2670b6e4b --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.html @@ -0,0 +1,2 @@ +
+
diff --git a/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts new file mode 100644 index 0000000000..37391a9261 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts @@ -0,0 +1,627 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/asmt'; +import 'vs/css!./media/detailview'; + +import * as nls from 'vs/nls'; +import * as azdata from 'azdata'; +import * as dom from 'vs/base/browser/dom'; +import { Component, Inject, forwardRef, ElementRef, ChangeDetectorRef, ViewChild, OnInit, OnDestroy, AfterContentChecked } from '@angular/core'; +import { TabChild } from 'sql/base/browser/ui/panel/tab.component'; +import { Table } from 'sql/base/browser/ui/table/table'; +import { AsmtViewComponent } from 'sql/workbench/contrib/assessment/browser/asmtView.component'; +import { HeaderFilter } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin'; +import { CommonServiceInterface } from 'sql/workbench/services/bootstrap/browser/commonServiceInterface.service'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IDashboardService } from 'sql/platform/dashboard/browser/dashboardService'; +import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import { IColorTheme } from 'vs/platform/theme/common/themeService'; +import { attachButtonStyler } from 'sql/platform/theme/common/styler'; +import { find } from 'vs/base/common/arrays'; +import { RowDetailView, ExtendedItem } from 'sql/base/browser/ui/table/plugins/rowDetailView'; +import { + IAssessmentComponent, + IAsmtActionInfo, + AsmtServerSelectItemsAction, + AsmtServerInvokeItemsAction, + AsmtDatabaseSelectItemsAction, + AsmtDatabaseInvokeItemsAction, + AsmtExportAsScriptAction, + AsmtSamplesLinkAction +} from 'sql/workbench/contrib/assessment/common/asmtActions'; +import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; +import { IAction } from 'vs/base/common/actions'; +import * as Utils from 'sql/platform/connection/common/utils'; +import { escape } from 'sql/base/common/strings'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { AssessmentType, TARGET_ICON_CLASS } from 'sql/workbench/contrib/assessment/common/consts'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IWorkbenchLayoutService, Parts } from 'vs/workbench/services/layout/browser/layoutService'; +import * as themeColors from 'vs/workbench/common/theme'; +import { ITableStyles } from 'sql/base/browser/ui/table/interfaces'; +import { TelemetryView } from 'sql/platform/telemetry/common/telemetryKeys'; + +export const ASMTRESULTSVIEW_SELECTOR: string = 'asmt-results-view-component'; +export const ROW_HEIGHT: number = 25; +export const ACTIONBAR_PADDING: number = 10; + +const PLACEHOLDER_LABEL = nls.localize('asmt.NoResultsInitial', "Nothing to show. Invoke assessment to get results"); +const COLUMN_MESSAGE_ID: string = 'message'; + +const COLUMN_MESSAGE_TITLE: { [mode: number]: string } = { + [AssessmentType.AvailableRules]: nls.localize('asmt.column.displayName', "Display Name"), + [AssessmentType.InvokeAssessment]: nls.localize('asmt.column.message', "Message"), +}; + +enum AssessmentResultItemKind { + RealResult = 0, + Warning = 1, + Error = 2 +} + +const KIND_CLASS: { [kind: number]: string } = { + [AssessmentResultItemKind.Error]: 'error-val', + [AssessmentResultItemKind.Warning]: 'warning-val', + [AssessmentResultItemKind.RealResult]: '' +}; + +@Component({ + selector: ASMTRESULTSVIEW_SELECTOR, + templateUrl: decodeURI(require.toUrl('./asmtResultsView.component.html')), + providers: [{ provide: TabChild, useExisting: forwardRef(() => AsmtResultsViewComponent) }], +}) + +export class AsmtResultsViewComponent extends TabChild implements IAssessmentComponent, OnInit, OnDestroy, AfterContentChecked { + protected _parentComponent: AsmtViewComponent; + protected _table: Table; + protected _visibilityElement: ElementRef; + protected isVisible: boolean = false; + protected isInitialized: boolean = false; + protected isRefreshing: boolean = false; + protected _actionBar: Taskbar; + + private columns: Array> = [ + { + name: nls.localize('asmt.column.target', "Target"), + formatter: this.renderTarget, + field: 'targetName', + width: 80, + id: 'target' + }, + { name: nls.localize('asmt.column.severity', "Serverity"), field: 'severity', maxWidth: 90, id: 'severity' }, + { + name: nls.localize('asmt.column.message', "Message"), + field: 'message', + width: 300, + id: COLUMN_MESSAGE_ID, + formatter: (_row, _cell, _value, _columnDef, dataContext) => this.appendHelplink(dataContext.message, dataContext.helpLink, dataContext.kind, this.wrapByKind), + }, + { + name: nls.localize('asmt.column.tags', "Tags"), + field: 'tags', + width: 80, + id: 'tags', + formatter: (row, cell, value, columnDef, dataContext) => this.renderTags(row, cell, value, columnDef, dataContext) + }, + { name: nls.localize('asmt.column.checkId', "Check ID"), field: 'checkId', maxWidth: 140, id: 'checkId' } + ]; + private dataView: any; + private filterPlugin: any; + private isServerMode: boolean; + private rowDetail: RowDetailView; + private exportActionItem: IAction; + private placeholderElem: HTMLElement; + private placeholderNoResultsLabel: string; + private spinner: { [mode: number]: HTMLElement } = Object.create(null); + private lastInvokedResults: azdata.SqlAssessmentResultItem[]; + + @ViewChild('resultsgrid') _gridEl: ElementRef; + @ViewChild('actionbarContainer') protected actionBarContainer: ElementRef; + + constructor( + @Inject(forwardRef(() => CommonServiceInterface)) private _commonService: CommonServiceInterface, + @Inject(forwardRef(() => ChangeDetectorRef)) private _cd: ChangeDetectorRef, + @Inject(forwardRef(() => AsmtViewComponent)) private _asmtViewComponent: AsmtViewComponent, + @Inject(IWorkbenchThemeService) private readonly _themeService: IWorkbenchThemeService, + @Inject(IWorkbenchLayoutService) private readonly layoutService: IWorkbenchLayoutService, + @Inject(IInstantiationService) private _instantiationService: IInstantiationService, + @Inject(IDashboardService) _dashboardService: IDashboardService, + @Inject(IAdsTelemetryService) private _telemetryService: IAdsTelemetryService, + @Inject(ILogService) protected _logService: ILogService + ) { + super(); + let self = this; + let profile = this._commonService.connectionManagementService.connectionInfo.connectionProfile; + + this.isServerMode = !profile.databaseName || Utils.isMaster(profile); + + if (this.isServerMode) { + this.placeholderNoResultsLabel = nls.localize('asmt.TargetInstanceComplient', "Instance {0} is totally compliant with the best practices. Good job!", profile.serverName); + } else { + this.placeholderNoResultsLabel = nls.localize('asmt.TargetDatabaseComplient', "Database {0} is totally compliant with the best practices. Good job!", profile.databaseName); + } + + this._register(_dashboardService.onLayout(d => self.layout())); + this._register(_themeService.onDidColorThemeChange(this._updateStyles, this)); + } + + ngOnInit(): void { + this._visibilityElement = this._gridEl; + this._parentComponent = this._asmtViewComponent; + this._telemetryService.sendViewEvent(TelemetryView.SqlAssessment); + } + + ngOnDestroy(): void { + this.isVisible = false; + } + + ngAfterContentChecked(): void { + if (this._visibilityElement && this._parentComponent) { + if (this.isVisible === false && this._visibilityElement.nativeElement.offsetParent !== null) { + this.isVisible = true; + if (!this.isInitialized) { + this.initializeComponent(); + this.layout(); + this.isInitialized = true; + } + } else if (this.isVisible === true && this._visibilityElement.nativeElement.offsetParent === null) { + this.isVisible = false; + } + } + } + + public get resultItems(): azdata.SqlAssessmentResultItem[] { + return this.lastInvokedResults; + } + + public get isActive(): boolean { + return this.isVisible; + } + + public layout(): void { + let statusBar = this.layoutService.getContainer(Parts.STATUSBAR_PART); + if (dom.isInDOM(this.actionBarContainer.nativeElement) && dom.isInDOM(statusBar)) { + let toolbarBottom = this.actionBarContainer.nativeElement.getBoundingClientRect().bottom + ACTIONBAR_PADDING; + let statusTop = statusBar.getBoundingClientRect().top; + this._table.layout(new dom.Dimension( + dom.getContentWidth(this._gridEl.nativeElement), + statusTop - toolbarBottom)); + + let gridCanvasWidth = this._table.grid.getCanvasNode().clientWidth; + let placeholderWidth = dom.getDomNodePagePosition(this.placeholderElem).width; + dom.position(this.placeholderElem, null, null, null, (gridCanvasWidth - placeholderWidth) / 2, 'relative'); + + this._updateStyles(this._themeService.getColorTheme()); + } + } + + public showProgress(mode: AssessmentType) { + this.spinner[mode].style.visibility = 'visible'; + + if (this.isVisible) { + this._cd.detectChanges(); + } + } + + public showInitialResults(result: azdata.SqlAssessmentResult, method: AssessmentType) { + if (result) { + if (method === AssessmentType.InvokeAssessment) { + this.lastInvokedResults = result.items; + } else { + this.lastInvokedResults = []; + } + + this.displayResults(result.items, method); + if (result.items.length > 0) { + this._asmtViewComponent.displayAssessmentInfo(result.apiVersion, result.items[0].rulesetVersion); + } + } + + if (this.isVisible) { + this._cd.detectChanges(); + } + + this._table.grid.invalidate(); + } + + public appendResults(result: azdata.SqlAssessmentResult, method: AssessmentType) { + if (method === AssessmentType.InvokeAssessment) { + this.lastInvokedResults.push(...result.items); + } + + if (result) { + this.dataView.beginUpdate(); + result.items.forEach((asmtResult, index) => { + this.dataView.addItem(this.convertToDataViewItems(asmtResult, index, method)); + }); + + this.dataView.reSort(); + this.dataView.endUpdate(); + this.dataView.refresh(); + this._table.autosizeColumns(); + this._table.resizeCanvas(); + } + + if (this.isVisible) { + this._cd.detectChanges(); + } + + this._table.grid.invalidate(); + } + + public stopProgress(mode: AssessmentType) { + this.spinner[mode].style.visibility = 'hidden'; + if (this.isVisible) { + this._cd.detectChanges(); + } + } + + private initializeComponent() { + let columns = this.columns.map((column) => { + column.rerenderOnResize = true; + return column; + }); + let options = >{ + syncColumnCellResize: true, + enableColumnReorder: false, + rowHeight: ROW_HEIGHT, + enableCellNavigation: true, + forceFitColumns: false + }; + + this.dataView = new Slick.Data.DataView({ inlineFilters: false }); + + let rowDetail = new RowDetailView({ + cssClass: '_detail_selector', + process: (item) => { + (rowDetail).onAsyncResponse.notify({ + 'itemDetail': item, + }, undefined, this); + }, + useRowClick: true, + panelRows: 2, + postTemplate: (itemDetail) => this.appendHelplink(itemDetail.description, itemDetail.helpLink, itemDetail.kind, this.wrapByKind), + preTemplate: () => '', + loadOnce: true + }); + + this.rowDetail = rowDetail; + let columnDef = this.rowDetail.getColumnDefinition(); + columnDef.formatter = (row, cell, value, columnDef, dataContext) => this.detailSelectionFormatter(row, cell, value, columnDef, dataContext as ExtendedItem); + columns.unshift(columnDef); + + let filterPlugin = new HeaderFilter(); + this._register(attachButtonStyler(filterPlugin, this._themeService)); + this.filterPlugin = filterPlugin; + this.filterPlugin.onFilterApplied.subscribe((e, args) => { + let filterValues = args.column.filterValues; + if (filterValues) { + this.dataView.refresh(); + this._table.grid.resetActiveCell(); + } + }); + this.filterPlugin.onCommand.subscribe((e, args: any) => { + this.columnSort(args.column.field, args.command === 'sort-asc'); + }); + + // we need to be able to show distinct array values in filter dialog for columns with array data + filterPlugin['getFilterValues'] = this.getFilterValues; + filterPlugin['getAllFilterValues'] = this.getAllFilterValues; + filterPlugin['getFilterValuesByInput'] = this.getFilterValuesByInput; + + dom.clearNode(this._gridEl.nativeElement); + dom.clearNode(this.actionBarContainer.nativeElement); + + + if (this.isServerMode) { + this.initActionBar( + this._register(this._instantiationService.createInstance(AsmtServerInvokeItemsAction)), + this._register(this._instantiationService.createInstance(AsmtServerSelectItemsAction))); + } else { + let connectionInfo = this._commonService.connectionManagementService.connectionInfo; + let databaseSelectAsmt = this._register(this._instantiationService.createInstance(AsmtDatabaseSelectItemsAction, connectionInfo.connectionProfile.databaseName)); + let databaseInvokeAsmt = this._register(this._instantiationService.createInstance(AsmtDatabaseInvokeItemsAction, connectionInfo.connectionProfile.databaseName)); + this.initActionBar(databaseInvokeAsmt, databaseSelectAsmt); + } + + this._table = this._register(new Table(this._gridEl.nativeElement, { columns }, options)); + this._table.grid.setData(this.dataView, true); + this._table.registerPlugin(this.rowDetail); + this._table.registerPlugin(filterPlugin); + + + this.placeholderElem = document.createElement('span'); + this.placeholderElem.className = 'placeholder'; + this.placeholderElem.innerText = PLACEHOLDER_LABEL; + dom.append(this._table.grid.getCanvasNode(), this.placeholderElem); + } + + private initActionBar(invokeAction: IAction, selectAction: IAction) { + this.exportActionItem = this._register(this._instantiationService.createInstance(AsmtExportAsScriptAction)); + + let taskbar = this.actionBarContainer.nativeElement; + this._actionBar = this._register(new Taskbar(taskbar)); + this.spinner[AssessmentType.InvokeAssessment] = Taskbar.createTaskbarSpinner(); + this.spinner[AssessmentType.AvailableRules] = Taskbar.createTaskbarSpinner(); + + this._actionBar.setContent([ + { action: invokeAction }, + { element: this.spinner[AssessmentType.InvokeAssessment] }, + { action: selectAction }, + { element: this.spinner[AssessmentType.AvailableRules] }, + { action: this.exportActionItem }, + { action: this._instantiationService.createInstance(AsmtSamplesLinkAction) } + ]); + + let connectionInfo = this._commonService.connectionManagementService.connectionInfo; + let context: IAsmtActionInfo = { component: this, ownerUri: Utils.generateUri(connectionInfo.connectionProfile.clone(), 'dashboard'), connectionId: connectionInfo.connectionProfile.id }; + this._actionBar.context = context; + this.exportActionItem.enabled = false; + } + + private convertToDataViewItems(asmtResult: azdata.SqlAssessmentResultItem, index: number, method: AssessmentType) { + return { + id: `${asmtResult.targetType}${this.escapeId(asmtResult.targetName)}${asmtResult.checkId}${index}`, + severity: asmtResult.level, + message: method === AssessmentType.InvokeAssessment ? asmtResult.message : asmtResult.displayName, + tags: this.clearOutDefaultRuleset(asmtResult.tags), + checkId: asmtResult.checkId, + targetName: asmtResult.targetName, + targetType: asmtResult.targetType, + helpLink: asmtResult.helpLink, + description: method === AssessmentType.InvokeAssessment ? asmtResult.message : asmtResult.description, + mode: method, + kind: asmtResult.kind !== undefined ? asmtResult.kind : AssessmentResultItemKind.RealResult + }; + } + + private displayResults(results: azdata.SqlAssessmentResultItem[], method: AssessmentType) { + this._table.grid.updateColumnHeader(COLUMN_MESSAGE_ID, COLUMN_MESSAGE_TITLE[method]); + + let resultViews = results.map((item, index) => this.convertToDataViewItems(item, index, method)); + + this.dataView.beginUpdate(); + this.dataView.setItems(resultViews); + this.dataView.setFilter((item) => this.filter(item)); + this.dataView.endUpdate(); + this.dataView.refresh(); + + this._table.autosizeColumns(); + this._table.resizeCanvas(); + this.exportActionItem.enabled = (results.length > 0 && method === AssessmentType.InvokeAssessment); + + if (results.length > 0) { + dom.hide(this.placeholderElem); + } else { + this.placeholderElem.innerText = this.placeholderNoResultsLabel; + } + } + + private escapeId(value: string): string { + return escape(value).replace(/[*//]/g, function (match) { + switch (match) { + case '*': + case '/': + return '_'; + default: + return match; + } + }); + } + + private clearOutDefaultRuleset(tags: string[]): string[] { + let idx = tags.indexOf('DefaultRuleset'); + if (idx > -1) { + tags.splice(idx, 1); + } + return tags; + } + + private columnSort(field: string, isAscending: boolean) { + this.dataView.sort((item1, item2) => { + if (item1.checkId === undefined || item2.checkId === undefined) { + return; + } + switch (field) { + case 'tags': + return item1.tags.toString().localeCompare(item2.tags.toString()); + case 'targetName': + if (item1.targetType > item2.targetType) { + return 1; + } else if (item1.targetType < item2.targetType) { + return -1; + } else { + return item1.targetName.localeCompare(item2.targetName); + } + } + return item1[field].localeCompare(item2[field]); + }, isAscending); + } + + private filter(item: any) { + let columns = this._table.grid.getColumns(); + let value = true; + for (let i = 0; i < columns.length; i++) { + let col: any = columns[i]; + let filterValues = col.filterValues; + if (filterValues && filterValues.length > 0) { + if (item._parent) { + value = value && find(filterValues, x => x === item._parent[col.field]); + } else { + let colValue = item[col.field]; + if (colValue instanceof Array) { + value = value && find(filterValues, x => colValue.indexOf(x) >= 0); + } else { + value = value && find(filterValues, x => x === colValue); + } + } + } + } + return value; + } + + private wrapByKind(kind: AssessmentResultItemKind, element: string): string { + if (kind !== AssessmentResultItemKind.RealResult) { + return `${element}`; + } + return element; + } + + private appendHelplink(msg: string, helpLink: string, kind: AssessmentResultItemKind, wrapByKindFunc): string { + if (msg !== undefined) { + return `${wrapByKindFunc(kind, escape(msg))}${nls.localize('asmt.learnMore', "Learn More")}`; + } + return undefined; + } + + + private renderTags(_row, _cell, _value, _columnDef, dataContext) { + if (dataContext.tags !== undefined) { + return dataContext.tags.join(`, `); + } + return dataContext.tags; + } + + private renderTarget(_row, _cell, _value, _columnDef, dataContext) { + return `
${dataContext.targetName}
`; + } + + private detailSelectionFormatter(_row: number, _cell: number, _value: any, _columnDef: Slick.Column, dataContext: Slick.SlickData): string | undefined { + + if (dataContext._collapsed === undefined) { + dataContext._collapsed = true; + dataContext._sizePadding = 0; //the required number of pading rows + dataContext._height = 0; //the actual height in pixels of the detail field + dataContext._isPadding = false; + dataContext._parent = undefined; + } + + if (dataContext._isPadding === true) { + //render nothing + } else if (dataContext._collapsed) { + return '
'; + } else { + const html: Array = []; + const rowHeight = ROW_HEIGHT; + const bottomMargin = 5; + html.push('
'); + + html.push(`
`); //shift detail below 1st row + html.push(`
`); //sub ctr for custom styling + html.push(`
${dataContext._detailContent!}
`); + return html.join(''); + } + return undefined; + } + + private getFilterValues(dataView: Slick.DataProvider, column: Slick.Column): Array { + const seen: Array = []; + for (let i = 0; i < dataView.getLength(); i++) { + const value = dataView.getItem(i)[column.field!]; + if (value instanceof Array) { + for (let item = 0; item < value.length; item++) { + if (!seen.some(x => x === value[item])) { + seen.push(value[item]); + } + } + } else { + if (!seen.some(x => x === value)) { + seen.push(value); + } + } + } + return seen; + } + + private getAllFilterValues(data: Array, column: Slick.Column) { + const seen: Array = []; + for (let i = 0; i < data.length; i++) { + const value = data[i][column.field!]; + if (value instanceof Array) { + for (let item = 0; item < value.length; item++) { + if (!seen.some(x => x === value[item])) { + seen.push(value[item]); + } + } + } else { + if (!seen.some(x => x === value)) { + seen.push(value); + } + } + } + + return seen.sort((v) => { return v; }); + } + + private getFilterValuesByInput($input: JQuery): Array { + const column = $input.data('column'), + filter = $input.val() as string, + dataView = this['grid'].getData() as Slick.DataProvider, + seen: Array = []; + + for (let i = 0; i < dataView.getLength(); i++) { + const value = dataView.getItem(i)[column.field]; + if (value instanceof Array) { + if (filter.length > 0) { + const itemValue = !value ? [] : value; + const lowercaseFilter = filter.toString().toLowerCase(); + const lowercaseVals = itemValue.map(v => v.toLowerCase()); + for (let valIdx = 0; valIdx < value.length; valIdx++) { + if (!seen.some(x => x === value[valIdx]) && lowercaseVals[valIdx].indexOf(lowercaseFilter) > -1) { + seen.push(value[valIdx]); + } + } + } + else { + for (let item = 0; item < value.length; item++) { + if (!seen.some(x => x === value[item])) { + seen.push(value[item]); + } + } + } + + } else { + if (filter.length > 0) { + const itemValue = !value ? '' : value; + const lowercaseFilter = filter.toString().toLowerCase(); + const lowercaseVal = itemValue.toString().toLowerCase(); + + if (!seen.some(x => x === value) && lowercaseVal.indexOf(lowercaseFilter) > -1) { + seen.push(value); + } + } + else { + if (!seen.some(x => x === value)) { + seen.push(value); + } + } + } + } + + return seen.sort((v) => { return v; }); + } + + private _updateStyles(theme: IColorTheme): void { + this.actionBarContainer.nativeElement.style.borderTopColor = theme.getColor(themeColors.DASHBOARD_BORDER, true).toString(); + let tableStyle: ITableStyles = { + tableHeaderBackground: theme.getColor(themeColors.PANEL_BACKGROUND) + }; + this._table.style(tableStyle); + const rowExclSelector = '.asmtview-grid > .monaco-table .slick-viewport > .grid-canvas > .ui-widget-content.slick-row'; + dom.removeCSSRulesContainingSelector(`${rowExclSelector} .${KIND_CLASS[AssessmentResultItemKind.Error]}`); + dom.createCSSRule(`${rowExclSelector} .${KIND_CLASS[AssessmentResultItemKind.Error]}`, `color: ${theme.getColor(themeColors.NOTIFICATIONS_ERROR_ICON_FOREGROUND).toString()}`); + + dom.removeCSSRulesContainingSelector(`${rowExclSelector} .${KIND_CLASS[AssessmentResultItemKind.Warning]}`); + dom.createCSSRule(`${rowExclSelector} .${KIND_CLASS[AssessmentResultItemKind.Warning]}`, `color: ${theme.getColor(themeColors.NOTIFICATIONS_WARNING_ICON_FOREGROUND).toString()}`); + + const detailRowSelector = '.asmtview-grid .grid-canvas > .ui-widget-content.slick-row .dynamic-cell-detail-color'; + dom.removeCSSRulesContainingSelector(detailRowSelector); + dom.createCSSRule(detailRowSelector, `background-color: ${theme.getColor(themeColors.EDITOR_GROUP_HEADER_TABS_BACKGROUND).toString()}`); + + } +} diff --git a/src/sql/workbench/contrib/assessment/browser/asmtView.component.html b/src/sql/workbench/contrib/assessment/browser/asmtView.component.html new file mode 100644 index 0000000000..daa8f6636d --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/asmtView.component.html @@ -0,0 +1,57 @@ + +
+
+
+
+ {{localizedStrings.SECTION_TITLE_API}} +
+
+
+
+ {{localizedStrings.API_VERSION}} + {{api}} +
+
+ {{localizedStrings.DEFAULT_RULESET_VERSION}} + {{ruleset}} +
+
+
+
+
+
+ {{localizedStrings.SECTION_TITLE_SQL_SERVER}} +
+
+
+
+ {{localizedStrings.SERVER_VERSION}} + {{connectionInfo.serverVersion}} +
+
+ {{localizedStrings.SERVER_EDITION}} + {{connectionInfo.serverEdition}} +
+
+
+
+ {{localizedStrings.SERVER_INSTANCENAME}} + {{instanceName}} +
+
+ {{localizedStrings.SERVER_OSVERSION}} + {{connectionInfo.osVersion}} +
+
+
+
+
+
+ +
+
diff --git a/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts b/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts new file mode 100644 index 0000000000..18dbd0f5b9 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/asmt'; +import { Component, Inject, forwardRef, ChangeDetectorRef, ViewChild, Injectable, OnInit } from '@angular/core'; +import { ServerInfo } from 'azdata'; +//import { PanelComponent, IPanelOptions, NavigationBarLayout } from 'sql/base/browser/ui/panel/panel.component'; +import { AngularDisposable } from 'sql/base/browser/lifecycle'; +import { localize } from 'vs/nls'; +import { CommonServiceInterface } from 'sql/workbench/services/bootstrap/browser/commonServiceInterface.service'; +import { AsmtResultsViewComponent } from 'sql/workbench/contrib/assessment/browser/asmtResultsView.component'; + + +const LocalizedStrings = { + SECTION_TITLE_API: localize('asmt.section.api.title', "API information"), + API_VERSION: localize('asmt.apiversion', "API Version:"), + DEFAULT_RULESET_VERSION: localize('asmt.rulesetversion', "Default Ruleset Version:"), + SECTION_TITLE_SQL_SERVER: localize('asmt.section.instance.title', "SQL Server Instance Details"), + SERVER_VERSION: localize('asmt.serverversion', "Version:"), + SERVER_EDITION: localize('asmt.serveredition', "Edition:"), + SERVER_INSTANCENAME: localize('asmt.instancename', "Instance Name:"), + SERVER_OSVERSION: localize('asmt.osversion', "OS Version:") +}; + +export const DASHBOARD_SELECTOR: string = 'asmtview-component'; + +@Component({ + selector: DASHBOARD_SELECTOR, + templateUrl: decodeURI(require.toUrl('./asmtView.component.html')) +}) +@Injectable() +export class AsmtViewComponent extends AngularDisposable implements OnInit { + + @ViewChild('asmtresultcomponent') private _asmtResultView: AsmtResultsViewComponent; + protected localizedStrings = LocalizedStrings; + + connectionInfo: ServerInfo = null; + instanceName: string = ''; + ruleset: string = ''; + api: string = ''; + + + + + constructor( + @Inject(forwardRef(() => ChangeDetectorRef)) private _cd: ChangeDetectorRef, + @Inject(forwardRef(() => CommonServiceInterface)) private _commonService: CommonServiceInterface) { + super(); + } + + ngOnInit() { + this.displayConnectionInfo(); + } + + private displayConnectionInfo() { + this.connectionInfo = this._commonService.connectionManagementService.connectionInfo.serverInfo; + let serverName = this._commonService.connectionManagementService.connectionInfo.connectionProfile.serverName; + let machineName = this.connectionInfo['machineName']; + if ((['local', '(local)', '(local);'].indexOf(serverName.toLowerCase()) >= 0) || machineName.toLowerCase() === serverName.toLowerCase()) { + this.instanceName = machineName; + } + else { + this.instanceName = machineName + '\\' + serverName; + } + } + + public displayAssessmentInfo(apiVersion: string, rulesetVersion: string) { + this.api = apiVersion; + this.ruleset = rulesetVersion; + this._cd.detectChanges(); + } + + public layout() { + this._asmtResultView.layout(); + //this._panel.layout(); + } +} diff --git a/src/sql/workbench/contrib/assessment/browser/media/asmt.css b/src/sql/workbench/contrib/assessment/browser/media/asmt.css new file mode 100644 index 0000000000..72b616a1ae --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/media/asmt.css @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +asmtview-component { + height: 100%; + width: 100%; + display: block; +} + +.asmt-heading { + font-weight: bold; + font-size: large; +} + +#asmtViewDiv .propertiesSectionTitle { + margin-bottom: 20px; + font-size: larger; +} + +#asmtViewDiv .propertyBlock { + display: inline-block; + margin: 0px 0px 20px 20px; +} + +#asmtViewDiv .propertyLabel { + font-size: 11px; +} + +.asmt-actionbar-container { + padding-bottom: 10px; + border-top: 3px solid; +} + +.asmtview-grid { + height: calc(100% - 75px); + width: 100%; + display: block; +} + +#asmtViewDiv .slick-header-column { + border: 0px !important; + font-weight: bold; +} + +.asmtview-grid>.monaco-table .slick-header-columns .slick-resizable-handle { + border-left: 1px dotted; +} + +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row>.slick-cell { + cursor: pointer; + border-right: none; +} + +.asmtview-grid>.monaco-table .slick-viewport>.grid-canvas>.ui-widget-content.slick-row .slick-cell>.excl, +.asmtview-grid .detail-container .excl { + width: 100%; + opacity: 1; + font-weight: 700; + text-overflow: ellipsis; +} + +#asmtDiv .detail { + padding: 5px; +} + +#asmtDiv .preload { + font-size: 13px; +} + +#asmtDiv .codicon.in-progress { + padding-left: 0px; +} + +#asmtDiv .carbon-taskbar .action-item { + margin-left: 0px; +} + +.asmt-actionbar-container .monaco-action-bar>ul.actions-container>li.action-item { + padding-left: 20px; +} + +.asmt-actionbar-container .actions-container .action-item .action-label { + padding-right: 0px; +} + +asmtview-component .asmtview-grid .slick-cell.error-row { + opacity: 0; +} + + +#asmtDiv asmtview-component .monaco-toolbar.carbon-taskbar { + margin: 10px 0px 10px 0px; +} + +#asmtDiv .helpLink { + margin-left: 5px; +} + +.vs asmtview-component .action-label.codicon.exportAsScriptIcon { + background-image: url("newquery.svg"); +} + +.vs-dark asmtview-component .action-label.codicon.exportAsScriptIcon, +.hc-black asmtview-component .action-label.codicon.exportAsScriptIcon { + background-image: url("newquery_inverse.svg"); +} + +.vs asmtview-component .action-label.codicon.asmt-learnmore { + background-image: url("configuredashboard.svg"); +} + +.vs-dark asmtview-component .action-label.codicon.asmt-learnmore, +.hc-black asmtview-component .action-label.codicon.asmt-learnmore { + background-image: url("configuredashboard_inverse.svg"); +} + +.asmtview-grid>.monaco-table .slick-viewport>.grid-canvas>.ui-widget-content.slick-row .slick-cell .codicon { + background-position: left; + padding-left: 18px; +} + +.asmt-actionbar-container .action-item>.action-label.codicon.database { + background-size: 12px; +} + +#asmtDiv .placeholder { + font-style: italic; + position: relative; + top: 50px; +} diff --git a/src/sql/workbench/contrib/assessment/browser/media/configuredashboard.svg b/src/sql/workbench/contrib/assessment/browser/media/configuredashboard.svg new file mode 100644 index 0000000000..f282b34c57 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/media/configuredashboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/sql/workbench/contrib/assessment/browser/media/configuredashboard_inverse.svg b/src/sql/workbench/contrib/assessment/browser/media/configuredashboard_inverse.svg new file mode 100644 index 0000000000..d217190556 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/media/configuredashboard_inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/sql/workbench/contrib/assessment/browser/media/detailview.css b/src/sql/workbench/contrib/assessment/browser/media/detailview.css new file mode 100644 index 0000000000..78b878a648 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/media/detailview.css @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row .detailView-toggle { + display: inline-block; + cursor: pointer; +} + +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row .dynamic-cell-detail { + z-index: 101; + position: absolute; + margin: 0; + padding: 0; + width: 100%; + overflow: auto; +} + + +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row .dynamic-cell-detail> :first-child { + vertical-align: middle; + line-height: 13px; + padding: 10px; + margin-left: 20px; +} + +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row .dynamic-cell-detail>.detail-container { + overflow: auto; + display: block !important; + max-height: 100px !important; + line-height: 20px; +} + +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row .dynamic-cell-detail>.detail-container>div, +.asmtview-grid .grid-canvas>.ui-widget-content.slick-row .dynamic-cell-detail>.detail-container>div>span { + white-space: normal; +} diff --git a/src/sql/workbench/contrib/assessment/browser/media/newquery.svg b/src/sql/workbench/contrib/assessment/browser/media/newquery.svg new file mode 100644 index 0000000000..e783cf3958 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/media/newquery.svg @@ -0,0 +1 @@ +newquery_16x16 \ No newline at end of file diff --git a/src/sql/workbench/contrib/assessment/browser/media/newquery_inverse.svg b/src/sql/workbench/contrib/assessment/browser/media/newquery_inverse.svg new file mode 100644 index 0000000000..5e52f63628 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/browser/media/newquery_inverse.svg @@ -0,0 +1 @@ +newquery_inverse_16x16 \ No newline at end of file diff --git a/src/sql/workbench/contrib/assessment/common/asmtActions.ts b/src/sql/workbench/contrib/assessment/common/asmtActions.ts new file mode 100644 index 0000000000..1ac01af589 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/common/asmtActions.ts @@ -0,0 +1,227 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action } from 'vs/base/common/actions'; +import * as nls from 'vs/nls'; + +import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IAssessmentService } from 'sql/workbench/services/assessment/common/interfaces'; +import { SqlAssessmentResult, SqlAssessmentResultItem } from 'azdata'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { URI } from 'vs/base/common/uri'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { AssessmentType, AssessmentTargetType, TARGET_ICON_CLASS } from 'sql/workbench/contrib/assessment/common/consts'; +import { TelemetryView } from 'sql/platform/telemetry/common/telemetryKeys'; + +export interface IAssessmentComponent { + showProgress(mode: AssessmentType): any; + showInitialResults(result: SqlAssessmentResult, method: AssessmentType): any; + appendResults(result: SqlAssessmentResult, method: AssessmentType): any; + stopProgress(mode: AssessmentType): any; + resultItems: SqlAssessmentResultItem[]; + isActive: boolean; +} + + +export class IAsmtActionInfo { + ownerUri?: string; + component: IAssessmentComponent; + connectionId: string; +} + + + +abstract class AsmtServerAction extends Action { + constructor( + id: string, + label: string, + private asmtType: AssessmentType, + @IConnectionManagementService private _connectionManagement: IConnectionManagementService, + @ILogService protected _logService: ILogService, + @IAdsTelemetryService protected _telemetryService: IAdsTelemetryService + ) { + super(id, label, TARGET_ICON_CLASS[AssessmentTargetType.Server]); + } + + public async run(context: IAsmtActionInfo): Promise { + this._telemetryService.sendActionEvent(TelemetryView.SqlAssessment, this.id); + if (context && context.component) { + context.component.showProgress(this.asmtType); + let serverResults = this.getServerItems(context.ownerUri); + let connectionUri: string = this._connectionManagement.getConnectionUriFromId(context.connectionId); + let connection = this._connectionManagement.getConnection(connectionUri); + let databaseListResult = this._connectionManagement.listDatabases(connectionUri); + context.component.showInitialResults(await serverResults, this.asmtType); + let dbList = await databaseListResult; + if (dbList) { + for (let nDbName = 0; nDbName < dbList.databaseNames.length; nDbName++) { + if (!context.component.isActive) { + break; + } + let dbName = dbList.databaseNames[nDbName]; + let newUri = await this._connectionManagement.connectIfNotConnected(connection.cloneWithDatabase(dbName).clone()); + + this._logService.info(`Database ${dbName} assessment started`); + let dbResult = await this.getDatabaseItems(newUri); + this._logService.info(`Database ${dbName} assessment completed`); + + context.component.appendResults(dbResult, this.asmtType); + } + } + + context.component.stopProgress(this.asmtType); + + return true; + } + + return false; + } + + abstract getServerItems(ownerUri: string): Thenable; + abstract getDatabaseItems(ownerUri: string): Thenable; +} + + +export class AsmtServerSelectItemsAction extends AsmtServerAction { + public static ID = 'asmtaction.server.getitems'; + public static LABEL = nls.localize('asmtaction.server.getitems', "View applicable rules"); + + constructor( + @IConnectionManagementService _connectionManagement: IConnectionManagementService, + @ILogService _logService: ILogService, + @IAssessmentService private _assessmentService: IAssessmentService, + @IAdsTelemetryService _telemetryService: IAdsTelemetryService + ) { + super(AsmtServerSelectItemsAction.ID, AsmtServerSelectItemsAction.LABEL, + AssessmentType.AvailableRules, + _connectionManagement, + _logService, _telemetryService); + } + + getServerItems(ownerUri: string): Thenable { + return this._assessmentService.getAssessmentItems(ownerUri, AssessmentTargetType.Server); + } + + getDatabaseItems(ownerUri: string): Thenable { + return this._assessmentService.getAssessmentItems(ownerUri, AssessmentTargetType.Database); + } +} + +export class AsmtDatabaseSelectItemsAction extends Action { + public static ID = 'asmtaction.database.getitems'; + + constructor( + databaseName: string, + @IAssessmentService private _assessmentService: IAssessmentService, + @IAdsTelemetryService private _telemetryService: IAdsTelemetryService + ) { + super(AsmtDatabaseSelectItemsAction.ID, + nls.localize('asmtaction.database.getitems', "View applicable rules for {0}", databaseName), + TARGET_ICON_CLASS[AssessmentTargetType.Database]); + } + + public async run(context: IAsmtActionInfo): Promise { + this._telemetryService.sendActionEvent(TelemetryView.SqlAssessment, this.id); + if (context && context.component) { + context.component.showProgress(AssessmentType.AvailableRules); + let dbAsmtResults = await this._assessmentService.getAssessmentItems(context.ownerUri, AssessmentTargetType.Database); + context.component.showInitialResults(dbAsmtResults, AssessmentType.AvailableRules); + context.component.stopProgress(AssessmentType.AvailableRules); + return true; + } + return false; + } +} + + +export class AsmtServerInvokeItemsAction extends AsmtServerAction { + public static ID = 'asmtaction.server.invokeitems'; + public static LABEL = nls.localize('asmtaction.server.invokeitems', "Invoke Assessment"); + + constructor( + @IConnectionManagementService _connectionManagement: IConnectionManagementService, + @ILogService _logService: ILogService, + @IAssessmentService private _assessmentService: IAssessmentService, + @IAdsTelemetryService _telemetryService: IAdsTelemetryService + ) { + super(AsmtServerInvokeItemsAction.ID, AsmtServerInvokeItemsAction.LABEL, AssessmentType.InvokeAssessment, _connectionManagement, _logService, _telemetryService); + } + getServerItems(ownerUri: string): Thenable { + this._logService.info(`Requesting server items`); + return this._assessmentService.assessmentInvoke(ownerUri, AssessmentTargetType.Server); + } + + getDatabaseItems(ownerUri: string): Thenable { + return this._assessmentService.assessmentInvoke(ownerUri, AssessmentTargetType.Database); + } +} + +export class AsmtDatabaseInvokeItemsAction extends Action { + public static ID = 'asmtaction.database.invokeitems'; + + constructor( + databaseName: string, + @IAssessmentService private _assessmentService: IAssessmentService, + @IAdsTelemetryService private _telemetryService: IAdsTelemetryService + ) { + super(AsmtDatabaseInvokeItemsAction.ID, + nls.localize('asmtaction.database.invokeitems', "Invoke Assessment for {0}", databaseName), + TARGET_ICON_CLASS[AssessmentTargetType.Database]); + } + + public async run(context: IAsmtActionInfo): Promise { + this._telemetryService.sendActionEvent(TelemetryView.SqlAssessment, this.id); + if (context && context.component) { + context.component.showProgress(AssessmentType.InvokeAssessment); + let dbAsmtResults = await this._assessmentService.assessmentInvoke(context.ownerUri, AssessmentTargetType.Database); + context.component.showInitialResults(dbAsmtResults, AssessmentType.InvokeAssessment); + context.component.stopProgress(AssessmentType.InvokeAssessment); + return true; + } + return false; + } +} + +export class AsmtExportAsScriptAction extends Action { + public static ID = 'asmtaction.exportasscript'; + public static LABEL = nls.localize('asmtaction.exportasscript', "Export As Script"); + + constructor( + @IAssessmentService private _assessmentService: IAssessmentService, + @IAdsTelemetryService private _telemetryService: IAdsTelemetryService + ) { + super(AsmtExportAsScriptAction.ID, AsmtExportAsScriptAction.LABEL, 'exportAsScriptIcon'); + } + + public async run(context: IAsmtActionInfo): Promise { + this._telemetryService.sendActionEvent(TelemetryView.SqlAssessment, AsmtExportAsScriptAction.ID); + if (context && context.component && context.component.resultItems) { + await this._assessmentService.generateAssessmentScript(context.ownerUri, context.component.resultItems); + return true; + } + return false; + } +} + +export class AsmtSamplesLinkAction extends Action { + public static readonly ID = 'asmtaction.showsamples'; + public static readonly LABEL = nls.localize('asmtaction.showsamples', "View all rules and learn more on GitHub"); + public static readonly ICON = 'asmt-learnmore'; + private static readonly configHelpUri = 'https://aka.ms/sql-assessment-api'; + + constructor( + @IOpenerService private _openerService: IOpenerService, + @IAdsTelemetryService private _telemetryService: IAdsTelemetryService + + ) { + super(AsmtSamplesLinkAction.ID, AsmtSamplesLinkAction.LABEL, AsmtSamplesLinkAction.ICON); + } + + public async run(): Promise { + this._telemetryService.sendActionEvent(TelemetryView.SqlAssessment, AsmtSamplesLinkAction.ID); + return this._openerService.open(URI.parse(AsmtSamplesLinkAction.configHelpUri)); + } +} diff --git a/src/sql/workbench/contrib/assessment/common/consts.ts b/src/sql/workbench/contrib/assessment/common/consts.ts new file mode 100644 index 0000000000..b28c754786 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/common/consts.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum AssessmentTargetType { + Server = 1, + Database = 2 +} + +export enum AssessmentType { + AvailableRules = 1, + InvokeAssessment = 2 +} + +export const TARGET_ICON_CLASS: { [targetType: number]: string } = { + [AssessmentTargetType.Database]: 'database', + [AssessmentTargetType.Server]: 'server-page' +}; + + + diff --git a/src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts b/src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts new file mode 100644 index 0000000000..35b54bbcc5 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * 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 TypeMoq from 'typemoq'; +import * as assert from 'assert'; +import { AssessmentType, AssessmentTargetType } from 'sql/workbench/contrib/assessment/common/consts'; +import { + IAssessmentComponent, + AsmtServerInvokeItemsAction, + AsmtServerSelectItemsAction, + AsmtExportAsScriptAction, + AsmtSamplesLinkAction, + AsmtDatabaseInvokeItemsAction, + AsmtDatabaseSelectItemsAction +} from 'sql/workbench/contrib/assessment/common/asmtActions'; +import { AssessmentService } from 'sql/workbench/services/assessment/common/assessmentService'; +import { NullAdsTelemetryService } from 'sql/platform/telemetry/common/adsTelemetryService'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; +import { TestConnectionManagementService } from 'sql/platform/connection/test/common/testConnectionManagementService'; +import { NullLogService } from 'vs/platform/log/common/log'; +import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; +import { OpenerServiceStub } from 'sql/platform/opener/common/openerServiceStub'; +/** + * Class to test Assessment Management Actions + */ + +let assessmentResultItems: azdata.SqlAssessmentResultItem[] = [ + { checkId: 'check1' }, + { checkId: 'check2' }, + { checkId: 'check3' } +]; + +class AssessmentTestViewComponent implements IAssessmentComponent { + showProgress(mode: AssessmentType) { return undefined; } + showInitialResults(result: azdata.SqlAssessmentResult, method: AssessmentType) { return undefined; } + appendResults(result: azdata.SqlAssessmentResult, method: AssessmentType) { } + stopProgress(mode: AssessmentType) { return undefined; } + resultItems: azdata.SqlAssessmentResultItem[] = assessmentResultItems; + isActive: boolean = true; +} + +let mockAssessmentService: TypeMoq.Mock; +let mockAsmtViewComponent: TypeMoq.Mock; + +let assessmentResult: azdata.SqlAssessmentResult = { + success: true, + errorMessage: '', + apiVersion: '', + items: assessmentResultItems +}; + +// Tests +suite('Assessment Actions', () => { + + // Actions + setup(() => { + mockAsmtViewComponent = TypeMoq.Mock.ofType(AssessmentTestViewComponent); + + mockAssessmentService = TypeMoq.Mock.ofType(AssessmentService); + mockAssessmentService.setup(s => s.assessmentInvoke(TypeMoq.It.isAny(), AssessmentTargetType.Server)).returns(() => Promise.resolve(assessmentResult)); + mockAssessmentService.setup(s => s.assessmentInvoke(TypeMoq.It.isAny(), AssessmentTargetType.Database)).returns(() => Promise.resolve(assessmentResult)); + mockAssessmentService.setup(s => s.getAssessmentItems(TypeMoq.It.isAny(), AssessmentTargetType.Server)).returns(() => Promise.resolve(assessmentResult)); + mockAssessmentService.setup(s => s.getAssessmentItems(TypeMoq.It.isAny(), AssessmentTargetType.Database)).returns(() => Promise.resolve(assessmentResult)); + + let resultStatus: azdata.ResultStatus = { + success: true, + errorMessage: null + }; + mockAssessmentService.setup(s => s.generateAssessmentScript(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())).returns(() => Promise.resolve(resultStatus)); + }); + + function createConnectionManagementService(dbListResult: azdata.ListDatabasesResult): TypeMoq.Mock { + let connectionProfile = TypeMoq.Mock.ofType(ConnectionProfile); + connectionProfile.setup(cp => cp.cloneWithDatabase(TypeMoq.It.isAnyString())).returns(() => connectionProfile.object); + connectionProfile.setup(cp => cp.clone()).returns(() => connectionProfile.object); + let connectionManagementService = TypeMoq.Mock.ofType(TestConnectionManagementService); + connectionManagementService.setup(c => c.listDatabases(TypeMoq.It.isAny())).returns(() => Promise.resolve(dbListResult)); + connectionManagementService.setup(c => c.getConnectionUriFromId(TypeMoq.It.isAny())).returns(() => ''); + connectionManagementService.setup(c => c.getConnection(TypeMoq.It.isAny())).returns(() => connectionProfile.object); + connectionManagementService.setup(c => c.connectIfNotConnected(TypeMoq.It.isAny())).returns(() => Promise.resolve('')); + + return connectionManagementService; + } + + test('Get Server Assessment Items Action', async () => { + const dbListResult: azdata.ListDatabasesResult = { + databaseNames: ['db1', 'db2'] + }; + + const connectionManagementService = createConnectionManagementService(dbListResult); + + const action = new AsmtServerSelectItemsAction(connectionManagementService.object, new NullLogService(), mockAssessmentService.object, new NullAdsTelemetryService()); + assert.equal(action.id, AsmtServerSelectItemsAction.ID, 'Get Server Rules id action mismatch'); + assert.equal(action.label, AsmtServerSelectItemsAction.LABEL, 'Get Server Rules label action mismatch'); + + let result = await action.run({ ownerUri: '', component: mockAsmtViewComponent.object, connectionId: '' }); + assert.ok(result, 'Get Server Rules action should succeed'); + mockAsmtViewComponent.verify(s => s.showProgress(AssessmentType.AvailableRules), TypeMoq.Times.once()); + mockAssessmentService.verify(s => s.getAssessmentItems(TypeMoq.It.isAny(), AssessmentTargetType.Server), TypeMoq.Times.once()); + mockAsmtViewComponent.verify(s => s.showInitialResults(TypeMoq.It.isAny(), AssessmentType.AvailableRules), TypeMoq.Times.once()); + // should be executed for every db in database list + mockAssessmentService.verify(s => s.getAssessmentItems(TypeMoq.It.isAny(), AssessmentTargetType.Database), TypeMoq.Times.exactly(dbListResult.databaseNames.length)); + mockAsmtViewComponent.verify(s => s.appendResults(TypeMoq.It.isAny(), AssessmentType.AvailableRules), TypeMoq.Times.exactly(dbListResult.databaseNames.length)); + + mockAsmtViewComponent.verify(s => s.stopProgress(AssessmentType.AvailableRules), TypeMoq.Times.once()); + }); + + + test('Invoke Server Assessment Action', async () => { + const dbListResult: azdata.ListDatabasesResult = { + databaseNames: ['db1', 'db2'] + }; + + const connectionManagementService = createConnectionManagementService(dbListResult); + + const action = new AsmtServerInvokeItemsAction(connectionManagementService.object, new NullLogService(), mockAssessmentService.object, new NullAdsTelemetryService()); + assert.equal(action.id, AsmtServerInvokeItemsAction.ID, 'Invoke Server Assessment id action mismatch'); + assert.equal(action.label, AsmtServerInvokeItemsAction.LABEL, 'Invoke Server Assessment label action mismatch'); + + let result = await action.run({ ownerUri: '', component: mockAsmtViewComponent.object, connectionId: '' }); + assert.ok(result, 'Invoke Server Assessment action should succeed'); + mockAsmtViewComponent.verify(s => s.showProgress(AssessmentType.InvokeAssessment), TypeMoq.Times.once()); + mockAssessmentService.verify(s => s.assessmentInvoke(TypeMoq.It.isAny(), AssessmentTargetType.Server), TypeMoq.Times.once()); + mockAsmtViewComponent.verify(s => s.showInitialResults(TypeMoq.It.isAny(), AssessmentType.InvokeAssessment), TypeMoq.Times.once()); + // should be executed for every db in database list + mockAssessmentService.verify(s => s.assessmentInvoke(TypeMoq.It.isAny(), AssessmentTargetType.Database), TypeMoq.Times.exactly(dbListResult.databaseNames.length)); + mockAsmtViewComponent.verify(s => s.appendResults(TypeMoq.It.isAny(), AssessmentType.InvokeAssessment), TypeMoq.Times.exactly(dbListResult.databaseNames.length)); + + mockAsmtViewComponent.verify(s => s.stopProgress(AssessmentType.InvokeAssessment), TypeMoq.Times.once()); + }); + + test('Get Assessment Items Database Action', async () => { + const action = new AsmtDatabaseSelectItemsAction('databaseName', mockAssessmentService.object, new NullAdsTelemetryService()); + assert.equal(action.id, AsmtDatabaseSelectItemsAction.ID, 'Get Database Rules id action mismatch'); + + let result = await action.run({ ownerUri: '', component: mockAsmtViewComponent.object, connectionId: '' }); + assert.ok(result, 'Get Assessment Database action should succeed'); + mockAsmtViewComponent.verify(s => s.showProgress(AssessmentType.AvailableRules), TypeMoq.Times.once()); + mockAsmtViewComponent.verify(s => s.showInitialResults(TypeMoq.It.isAny(), AssessmentType.AvailableRules), TypeMoq.Times.once()); + mockAsmtViewComponent.verify(s => s.stopProgress(AssessmentType.AvailableRules), TypeMoq.Times.once()); + mockAssessmentService.verify(s => s.getAssessmentItems(TypeMoq.It.isAny(), AssessmentTargetType.Database), TypeMoq.Times.once()); + + }); + + test('Invoke Database Assessment Action', async () => { + const action = new AsmtDatabaseInvokeItemsAction('databaseName', mockAssessmentService.object, new NullAdsTelemetryService()); + assert.equal(action.id, AsmtDatabaseInvokeItemsAction.ID, 'Invoke Database Assessment id action mismatch'); + + let result = await action.run({ ownerUri: '', component: mockAsmtViewComponent.object, connectionId: '' }); + assert.ok(result, 'Invoke Database Assessment action should succeed'); + mockAsmtViewComponent.verify(s => s.showProgress(AssessmentType.InvokeAssessment), TypeMoq.Times.once()); + mockAsmtViewComponent.verify(s => s.showInitialResults(TypeMoq.It.isAny(), AssessmentType.InvokeAssessment), TypeMoq.Times.once()); + mockAsmtViewComponent.verify(s => s.stopProgress(AssessmentType.InvokeAssessment), TypeMoq.Times.once()); + mockAssessmentService.verify(s => s.assessmentInvoke(TypeMoq.It.isAny(), AssessmentTargetType.Database), TypeMoq.Times.once()); + + }); + + test('Generate Script Action', async () => { + const action = new AsmtExportAsScriptAction(mockAssessmentService.object, new NullAdsTelemetryService()); + assert.equal(action.id, AsmtExportAsScriptAction.ID, 'Generate Assessment script id action mismatch'); + assert.equal(action.label, AsmtExportAsScriptAction.LABEL, 'Generate Assessment script label action mismatch'); + + let result = await action.run({ ownerUri: '', component: mockAsmtViewComponent.object, connectionId: '' }); + assert.ok(result, 'Generate Script action should succeed'); + mockAssessmentService.verify(s => s.generateAssessmentScript(TypeMoq.It.isAnyString(), TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); + + test('Samples Link Action', async () => { + let openerService = TypeMoq.Mock.ofType(OpenerServiceStub); + openerService.setup(s => s.open(TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + + const action = new AsmtSamplesLinkAction(openerService.object, new NullAdsTelemetryService()); + assert.equal(action.id, AsmtSamplesLinkAction.ID, 'Samples Link id action mismatch'); + assert.equal(action.label, AsmtSamplesLinkAction.LABEL, 'Samples Link label action mismatch'); + + let result = await action.run(); + assert.ok(result, 'Samples Link action should succeed'); + openerService.verify(s => s.open(TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); + +}); diff --git a/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.html b/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.html index 5fcba550bf..a531fce50b 100644 --- a/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.html +++ b/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.html @@ -6,3 +6,4 @@ --> + diff --git a/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.ts b/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.ts index df8fbed61e..91410b163d 100644 --- a/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.ts +++ b/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.ts @@ -13,6 +13,7 @@ import { CommonServiceInterface } from 'sql/workbench/services/bootstrap/browser import * as azdata from 'azdata'; import { memoize } from 'vs/base/common/decorators'; import { AgentViewComponent } from 'sql/workbench/contrib/jobManagement/browser/agentView.component'; +import { AsmtViewComponent } from 'sql/workbench/contrib/assessment/browser/asmtView.component'; @Component({ templateUrl: decodeURI(require.toUrl('./controlHostContent.component.html')), @@ -30,6 +31,7 @@ export class ControlHostContent { /* Children components */ @ViewChild('agent') private _agentViewComponent: AgentViewComponent; + @ViewChild('asmt') private _asmtViewComponent: AsmtViewComponent; constructor( @Inject(forwardRef(() => CommonServiceInterface)) private _dashboardService: CommonServiceInterface, @@ -38,7 +40,8 @@ export class ControlHostContent { } public layout(): void { - this._agentViewComponent.layout(); + this._agentViewComponent?.layout(); + this._asmtViewComponent?.layout(); } public get id(): string { @@ -71,6 +74,8 @@ export class ControlHostContent { } public refresh() { - this._agentViewComponent.refresh = true; + if (this._agentViewComponent !== undefined) { + this._agentViewComponent.refresh = true; + } } } diff --git a/src/sql/workbench/contrib/dashboard/browser/dashboard.module.ts b/src/sql/workbench/contrib/dashboard/browser/dashboard.module.ts index 8ef8d64aaf..ba15cb9327 100644 --- a/src/sql/workbench/contrib/dashboard/browser/dashboard.module.ts +++ b/src/sql/workbench/contrib/dashboard/browser/dashboard.module.ts @@ -60,12 +60,14 @@ import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox.component'; import { SelectBox } from 'sql/platform/browser/selectBox/selectBox.component'; import { InputBox } from 'sql/platform/browser/inputbox/inputBox.component'; import { EditableDropDown } from 'sql/platform/browser/editableDropdown/editableDropdown.component'; +import { AsmtViewComponent } from 'sql/workbench/contrib/assessment/browser/asmtView.component'; +import { AsmtResultsViewComponent } from 'sql/workbench/contrib/assessment/browser/asmtResultsView.component'; const baseComponents = [DashboardHomeContainer, DashboardComponent, DashboardWidgetWrapper, DashboardWebviewContainer, DashboardWidgetContainer, DashboardGridContainer, DashboardErrorContainer, DashboardNavSection, ModelViewContent, WebviewContent, WidgetContent, ComponentHostDirective, BreadcrumbComponent, ControlHostContent, DashboardControlHostContainer, JobsViewComponent, NotebooksViewComponent, AgentViewComponent, JobHistoryComponent, NotebookHistoryComponent, JobStepsViewComponent, AlertsViewComponent, ProxiesViewComponent, OperatorsViewComponent, - DashboardModelViewContainer, ModelComponentWrapper, Checkbox, EditableDropDown, SelectBox, InputBox]; + DashboardModelViewContainer, ModelComponentWrapper, Checkbox, EditableDropDown, SelectBox, InputBox, AsmtViewComponent, AsmtResultsViewComponent]; /* Panel */ import { PanelModule } from 'sql/base/browser/ui/panel/panel.module'; diff --git a/src/sql/workbench/services/assessment/common/assessmentService.ts b/src/sql/workbench/services/assessment/common/assessmentService.ts new file mode 100644 index 0000000000..e5398918b0 --- /dev/null +++ b/src/sql/workbench/services/assessment/common/assessmentService.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import * as azdata from 'azdata'; +import { IAssessmentService } from 'sql/workbench/services/assessment/common/interfaces'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; + +export class AssessmentService implements IAssessmentService { + _serviceBrand: undefined; + + private _onDidChange = new Emitter(); + public readonly onDidChange: Event = this._onDidChange.event; + + private _providers: { [handle: string]: azdata.SqlAssessmentServicesProvider; } = Object.create(null); + constructor( + @IConnectionManagementService private _connectionService: IConnectionManagementService + ) { + + } + + public getAssessmentItems(connectionUri: string, targetType: number): Thenable { + return this._runAction(connectionUri, (runner) => { + return runner.getAssessmentItems(connectionUri, targetType); + }); + } + + public assessmentInvoke(connectionUri: string, targetType: number): Thenable { + return this._runAction(connectionUri, (runner) => { + return runner.assessmentInvoke(connectionUri, targetType); + }); + } + + public generateAssessmentScript(connectionUri: string, items: azdata.SqlAssessmentResultItem[]): Thenable { + return this._runAction(connectionUri, (runner) => { + return runner.generateAssessmentScript(items); + }); + } + + public registerProvider(providerId: string, provider: azdata.SqlAssessmentServicesProvider): void { + this._providers[providerId] = provider; + } + + private _runAction(uri: string, action: (handler: azdata.SqlAssessmentServicesProvider) => Thenable): Thenable { + let providerId: string = this._connectionService.getProviderIdFromUri(uri); + + if (!providerId) { + return Promise.reject(new Error(localize('asmt.providerIdNotValidError', "Connection is required in order to interact with Assessment Service"))); + } + let handler = this._providers[providerId]; + if (handler) { + return action(handler); + } else { + return Promise.reject(new Error(localize('asmt.noHandlerRegistered', "No Handler Registered"))); + } + } +} diff --git a/src/sql/workbench/services/assessment/common/interfaces.ts b/src/sql/workbench/services/assessment/common/interfaces.ts new file mode 100644 index 0000000000..5010459044 --- /dev/null +++ b/src/sql/workbench/services/assessment/common/interfaces.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const SERVICE_ID = 'assessmentService'; + +export const IAssessmentService = createDecorator(SERVICE_ID); + +export interface IAssessmentService { + _serviceBrand: undefined; + registerProvider(providerId: string, provider: azdata.SqlAssessmentServicesProvider): void; + getAssessmentItems(connectionUri: string, targetType: number): Thenable; + assessmentInvoke(connectionUri: string, targetType: number): Thenable; + generateAssessmentScript(connectionUri: string, items: azdata.SqlAssessmentResultItem[]): Thenable; +} diff --git a/src/sql/workbench/services/assessment/test/assessmentService.test.ts b/src/sql/workbench/services/assessment/test/assessmentService.test.ts new file mode 100644 index 0000000000..9473811c7d --- /dev/null +++ b/src/sql/workbench/services/assessment/test/assessmentService.test.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AssessmentService } from 'sql/workbench/services/assessment/common/assessmentService'; +import * as assert from 'assert'; + +// TESTS /////////////////////////////////////////////////////////////////// +suite('Assessment service tests', () => { + setup(() => { + }); + + test('Construction - Assessment service Initialization', () => { + let service = new AssessmentService(undefined); + assert(service); + }); + +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index e91722266c..b50759875c 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -191,6 +191,8 @@ import { DashboardService } from 'sql/platform/dashboard/browser/dashboardServic import { NotebookService } from 'sql/workbench/services/notebook/browser/notebookServiceImpl'; import { INotebookService } from 'sql/workbench/services/notebook/browser/notebookService'; import { IScriptingService, ScriptingService } from 'sql/platform/scripting/common/scriptingService'; +import { IAssessmentService } from 'sql/workbench/services/assessment/common/interfaces'; +import { AssessmentService } from 'sql/workbench/services/assessment/common/assessmentService'; registerSingleton(IDashboardService, DashboardService); registerSingleton(IDashboardViewService, DashboardViewService); @@ -228,6 +230,7 @@ registerSingleton(IQueryEditorService, QueryEditorService); registerSingleton(IAdsTelemetryService, AdsTelemetryService); registerSingleton(IObjectExplorerService, ObjectExplorerService); registerSingleton(IOEShimService, OEShimService); +registerSingleton(IAssessmentService, AssessmentService); //#endregion