diff --git a/src/sql/media/icons/common-icons.css b/src/sql/media/icons/common-icons.css index ce01b02054..0f8d8d5886 100644 --- a/src/sql/media/icons/common-icons.css +++ b/src/sql/media/icons/common-icons.css @@ -233,6 +233,15 @@ background: url("unpin.svg") center center no-repeat; } +.hc-black .codicon.bookreport, +.vs-dark .codicon.bookreport { + background: url("book_inverse.svg") center center no-repeat; +} + +.vs .codicon.bookreport { + background: url("book.svg") center center no-repeat; +} + .vs .sql.codicon.pause { background-image: url("pause.svg"); } @@ -282,27 +291,35 @@ .codicon.toolbarIconRunInactive { background-image: url("execute_cell_grey.svg"); } + .codicon.toolbarIconRun { background-image: url("execute_cell.svg"); } + .codicon.toolbarIconRunError { background-image: url("execute_cell_error.svg"); } + .codicon.toolbarIconStop { background-image: url("stop_cell_solidanimation.svg"); } + .vs-dark .codicon.toolbarIconRunInactive { background-image: url("execute_cell_dark.svg"); } + .vs-dark .codicon.toolbarIconRun { background-image: url("execute_cell_white.svg"); } + .hc-black .codicon.toolbarIconRunInactive { background-image: url("execute_cell_hc.svg"); } + .hc-black .codicon.toolbarIconRun { background-image: url("execute_cell_orange_hc.svg"); } + .vs-dark .codicon.toolbarIconStop, .hc-black .codicon.toolbarIconStop { background-image: url("stop_cell_solidanimation_inverse.svg"); @@ -387,6 +404,7 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).icon-expand-cells { background-image: url("action-expand.svg"); } + .codicon.masked-icon.icon-expand-cells:before { background-image: none; -webkit-mask-image: url("action-expand.svg"); @@ -396,6 +414,7 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).icon-collapse-cells { background-image: url("action-collapse.svg"); } + .codicon.masked-icon.icon-collapse-cells:before { -webkit-mask-image: url("action-collapse.svg"); mask-image: url("action-collapse.svg"); @@ -404,6 +423,7 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).icon-clear-results { background-image: url("clear.svg"); } + .codicon.masked-icon.icon-clear-results:before { -webkit-mask-image: url("clear.svg"); mask-image: url("clear.svg"); @@ -412,6 +432,7 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).icon-shield { background-image: url("shield.svg"); } + .codicon.masked-icon.icon-shield:before { -webkit-mask-image: url("shield.svg"); mask-image: url("shield.svg"); @@ -420,6 +441,7 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).icon-shield-x { background-image: url("shield-x.svg"); } + .codicon.masked-icon.icon-shield-x:before { -webkit-mask-image: url("shield-x.svg"); mask-image: url("shield-x.svg"); @@ -428,6 +450,7 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).packages { background-image: url("packages.svg"); } + .codicon.masked-icon.packages:before { background-image: none; -webkit-mask-image: url("packages.svg"); @@ -437,6 +460,7 @@ Includes non-masked style declarations. */ .codicon.arrow-up { background-image: url("chevron_up.svg"); } + .vs-dark .codicon.arrow-up, .hc-black .codicon.arrow-up { background-image: url("chevron_up_inverse.svg"); @@ -445,10 +469,12 @@ Includes non-masked style declarations. */ .codicon:not(.masked-icon).arrow-down { background-image: url("chevron_down.svg"); } + .vs-dark .codicon:not(.masked-icon).arrow-down, .hc-black .codicon:not(.masked-icon).arrow-down { background-image: url("chevron_down_inverse.svg"); } + .codicon.masked-icon.arrow-down { background-image: none; -webkit-mask-image: url("chevron_down.svg"); @@ -458,6 +484,7 @@ Includes non-masked style declarations. */ .vs .codicon:not(.masked-icon).new-blue { background-image: url('new-blue.svg'); } + .vs .codicon:not(.masked-icon).start-outline { background-image: url('start-outline.svg'); } @@ -466,6 +493,7 @@ Includes non-masked style declarations. */ .masked-pseudo { background-image: none !important; } + .masked-pseudo:before, .masked-pseudo-after:after { content: ""; @@ -478,6 +506,7 @@ Includes non-masked style declarations. */ -webkit-mask-size: 50% 100%; mask-size: 50% 100%; } + .masked-pseudo:before { height: 23px; left: 0; @@ -485,21 +514,25 @@ Includes non-masked style declarations. */ transform: translateY(-50%); width: 30px; } + .masked-pseudo-after:after { height: 23px; right: 0; top: 2px; width: 30px; } + .masked-pseudo-after.dropdown-arrow:after { background-image: none; -webkit-mask-image: url("chevron_down.svg"); mask-image: url("chevron_down.svg"); } + .masked-pseudo.add-new:before { -webkit-mask-image: url("new.svg"); mask-image: url("new.svg"); } + .masked-pseudo.start-outline:before { -webkit-mask-image: url("start-outline.svg"); mask-image: url("start-outline.svg"); @@ -509,6 +542,7 @@ Includes non-masked style declarations. */ -webkit-mask-image: url("code.svg"); mask-image: url("code.svg"); } + .masked-pseudo.markdown:before { -webkit-mask-image: url("markdown.svg"); mask-image: url("markdown.svg"); @@ -519,32 +553,38 @@ Includes non-masked style declarations. */ -webkit-mask-image: url('new.svg'); mask-image: url('new.svg'); } + .codicon.masked-icon.close:before { background-image: none; -webkit-mask-image: url('close-blue.svg'); mask-image: url('close-blue.svg'); } + .codicon.masked-icon.edit:before { background-image: none; -webkit-mask-image: url('edit.svg'); mask-image: url('edit.svg'); } + .codicon.masked-icon.move-up { transform: scale(-1); background-image: none; -webkit-mask-image: url('down-arrow-blue.svg'); mask-image: url('down-arrow-blue.svg'); } + .codicon.masked-icon.move-down { background-image: none; -webkit-mask-image: url('down-arrow-blue.svg'); mask-image: url('down-arrow-blue.svg'); } + .codicon.masked-icon.delete:before { background-image: none; -webkit-mask-image: url('garbage-can-blue.svg'); mask-image: url('garbage-can-blue.svg'); } + .codicon.masked-icon.more:before { background-image: none; -webkit-mask-image: url('ellipsis-blue.svg'); diff --git a/src/sql/workbench/contrib/assessment/common/asmtActions.ts b/src/sql/workbench/contrib/assessment/browser/asmtActions.ts similarity index 72% rename from src/sql/workbench/contrib/assessment/common/asmtActions.ts rename to src/sql/workbench/contrib/assessment/browser/asmtActions.ts index 1ac01af589..853a9c8bb3 100644 --- a/src/sql/workbench/contrib/assessment/common/asmtActions.ts +++ b/src/sql/workbench/contrib/assessment/browser/asmtActions.ts @@ -5,23 +5,36 @@ 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 { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IAssessmentService } from 'sql/workbench/services/assessment/common/interfaces'; -import { SqlAssessmentResult, SqlAssessmentResultItem } from 'azdata'; +import { SqlAssessmentResult } from 'azdata'; import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IFileService } from 'vs/platform/files/common/files'; 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'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import * as path from 'vs/base/common/path'; +import { HTMLReportBuilder } from 'sql/workbench/contrib/assessment/common/htmlReportGenerator'; +import Severity from 'vs/base/common/severity'; +import { INotificationService } from 'vs/platform/notification/common/notification'; + +export interface SqlAssessmentResultInfo { + result: SqlAssessmentResult; + dateUpdated: number; + connectionInfo: any +} 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[]; + recentResult: SqlAssessmentResultInfo; isActive: boolean; } @@ -136,7 +149,6 @@ export class AsmtDatabaseSelectItemsAction extends Action { } } - export class AsmtServerInvokeItemsAction extends AsmtServerAction { public static ID = 'asmtaction.server.invokeitems'; public static LABEL = nls.localize('asmtaction.server.invokeitems', "Invoke Assessment"); @@ -198,8 +210,9 @@ export class AsmtExportAsScriptAction extends Action { 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); + const items = context?.component?.recentResult?.result.items; + if (items) { + await this._assessmentService.generateAssessmentScript(context.ownerUri, items); return true; } return false; @@ -225,3 +238,64 @@ export class AsmtSamplesLinkAction extends Action { return this._openerService.open(URI.parse(AsmtSamplesLinkAction.configHelpUri)); } } + +export class AsmtGenerateHTMLReportAction extends Action { + public static readonly ID = 'asmtaction.generatehtmlreport'; + public static readonly LABEL = nls.localize('asmtaction.generatehtmlreport', "Make HTML Report"); + public static readonly ICON = 'bookreport'; + + constructor( + @IFileService private _fileService: IFileService, + @IOpenerService private _openerService: IOpenerService, + @IEnvironmentService private _environmentService: IEnvironmentService, + @IAdsTelemetryService private _telemetryService: IAdsTelemetryService, + @INotificationService private readonly _notificationService: INotificationService, + @IFileDialogService private readonly _fileDialogService: IFileDialogService + ) { + super(AsmtGenerateHTMLReportAction.ID, AsmtGenerateHTMLReportAction.LABEL, AsmtGenerateHTMLReportAction.ICON); + } + + private suggestReportFile(date: number): URI { + const fileName = generateReportFileName(new Date(date)); + const filePath = path.join(this._environmentService.userRoamingDataHome.fsPath, 'SqlAssessmentReports', fileName); + return URI.file(filePath); + } + + public async run(context: IAsmtActionInfo): Promise { + context.component.showProgress(AssessmentType.ReportGeneration); + const choosenPath = await this._fileDialogService.pickFileToSave(this.suggestReportFile(context.component.recentResult.dateUpdated)); + context.component.stopProgress(AssessmentType.ReportGeneration); + if (!choosenPath) { + return false; + } + + this._telemetryService.sendActionEvent(TelemetryView.SqlAssessment, AsmtGenerateHTMLReportAction.ID); + + const result = await this._fileService.createFile( + choosenPath, + VSBuffer.fromString(new HTMLReportBuilder(context.component.recentResult.result, + context.component.recentResult.dateUpdated, + context.component.recentResult.connectionInfo).build()), + { overwrite: true }); + if (result) { + this._notificationService.prompt(Severity.Info, nls.localize('asmtaction.openReport', "Report has been saved. Do you want to open it?"), + [{ + label: nls.localize('asmtaction.label.open', "Open"), + run: () => { + return this._openerService.open(result.resource.fsPath, { openExternal: true }); + } + }, + { + label: nls.localize('asmtaction.label.cancel', "Cancel"), + run: () => { } + }]); + } + return true; + } +} + +function generateReportFileName(resultDate): string { + const datetime = `${resultDate.toISOString().replace(/-/g, '').replace('T', '').replace(/:/g, '').split('.')[0]}`; + return `SqlAssessmentReport_${datetime}.html`; + +} diff --git a/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts index c5c5fc82b5..f6bc2d35c4 100644 --- a/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts +++ b/src/sql/workbench/contrib/assessment/browser/asmtResultsView.component.ts @@ -25,13 +25,15 @@ import { RowDetailView, ExtendedItem } from 'sql/base/browser/ui/table/plugins/r import { IAssessmentComponent, IAsmtActionInfo, + SqlAssessmentResultInfo, AsmtServerSelectItemsAction, AsmtServerInvokeItemsAction, AsmtDatabaseSelectItemsAction, AsmtDatabaseInvokeItemsAction, AsmtExportAsScriptAction, - AsmtSamplesLinkAction -} from 'sql/workbench/contrib/assessment/common/asmtActions'; + AsmtSamplesLinkAction, + AsmtGenerateHTMLReportAction +} from 'sql/workbench/contrib/assessment/browser/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'; @@ -43,6 +45,8 @@ import { IWorkbenchLayoutService, Parts } from 'vs/workbench/services/layout/bro 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'; +import { LocalizedStrings } from 'sql/workbench/contrib/assessment/common/strings'; +import { ConnectionManagementInfo } from 'sql/platform/connection/common/connectionManagementInfo'; export const ASMTRESULTSVIEW_SELECTOR: string = 'asmt-results-view-component'; export const ROW_HEIGHT: number = 25; @@ -53,7 +57,7 @@ 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"), + [AssessmentType.InvokeAssessment]: LocalizedStrings.MESSAGE_COLUMN_NAME, }; enum AssessmentResultItemKind { @@ -93,30 +97,32 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom }, { name: nls.localize('asmt.column.severity', "Severity"), field: 'severity', maxWidth: 90, id: 'severity' }, { - name: nls.localize('asmt.column.message', "Message"), + name: LocalizedStrings.MESSAGE_COLUMN_NAME, 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"), + name: LocalizedStrings.TAGS_COLUMN_NAME, 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' } + { name: LocalizedStrings.CHECKID_COLUMN_NAME, field: 'checkId', maxWidth: 140, id: 'checkId' } ]; private dataView: any; private filterPlugin: any; private isServerMode: boolean; private rowDetail: RowDetailView; private exportActionItem: IAction; + private generateReportActionItem: IAction; private placeholderElem: HTMLElement; private placeholderNoResultsLabel: string; private spinner: { [mode: number]: HTMLElement } = Object.create(null); - private lastInvokedResults: azdata.SqlAssessmentResultItem[]; + private lastInvokedResult: SqlAssessmentResultInfo; + private initialConnectionInfo: ConnectionManagementInfo; @ViewChild('resultsgrid') _gridEl: ElementRef; @ViewChild('actionbarContainer') protected actionBarContainer: ElementRef; @@ -134,7 +140,7 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom ) { super(); let self = this; - let profile = this._commonService.connectionManagementService.connectionInfo.connectionProfile; + const profile = this._commonService.connectionManagementService.connectionInfo.connectionProfile; this.isServerMode = !profile.databaseName || Utils.isMaster(profile); @@ -146,6 +152,7 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom this._register(_dashboardService.onLayout(d => self.layout())); this._register(_themeService.onDidColorThemeChange(this._updateStyles, this)); + this.initialConnectionInfo = this._commonService.connectionManagementService.connectionInfo; } ngOnInit(): void { @@ -156,6 +163,8 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom ngOnDestroy(): void { this.isVisible = false; + this.rowDetail?.destroy(); + this.filterPlugin.destroy(); } ngAfterContentChecked(): void { @@ -173,8 +182,8 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom } } - public get resultItems(): azdata.SqlAssessmentResultItem[] { - return this.lastInvokedResults; + public get recentResult(): SqlAssessmentResultInfo { + return this.lastInvokedResult; } public get isActive(): boolean { @@ -209,9 +218,13 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom public showInitialResults(result: azdata.SqlAssessmentResult, method: AssessmentType) { if (result) { if (method === AssessmentType.InvokeAssessment) { - this.lastInvokedResults = result.items; + this.lastInvokedResult = { + result: result, + dateUpdated: Date.now(), + connectionInfo: this.initialConnectionInfo + }; } else { - this.lastInvokedResults = []; + this.lastInvokedResult = undefined; } this.displayResults(result.items, method); @@ -229,7 +242,8 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom public appendResults(result: azdata.SqlAssessmentResult, method: AssessmentType) { if (method === AssessmentType.InvokeAssessment) { - this.lastInvokedResults.push(...result.items); + this.lastInvokedResult.dateUpdated = Date.now(); + this.lastInvokedResult.result.items.push(...result.items); } if (result) { @@ -341,11 +355,13 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom private initActionBar(invokeAction: IAction, selectAction: IAction) { this.exportActionItem = this._register(this._instantiationService.createInstance(AsmtExportAsScriptAction)); + this.generateReportActionItem = this._register(this._instantiationService.createInstance(AsmtGenerateHTMLReportAction)); 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.spinner[AssessmentType.ReportGeneration] = Taskbar.createTaskbarSpinner(); this._actionBar.setContent([ { action: invokeAction }, @@ -353,6 +369,8 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom { action: selectAction }, { element: this.spinner[AssessmentType.AvailableRules] }, { action: this.exportActionItem }, + { action: this.generateReportActionItem }, + { element: this.spinner[AssessmentType.ReportGeneration] }, { action: this._instantiationService.createInstance(AsmtSamplesLinkAction) } ]); @@ -360,6 +378,7 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom let context: IAsmtActionInfo = { component: this, ownerUri: Utils.generateUri(connectionInfo.connectionProfile.clone(), 'dashboard'), connectionId: connectionInfo.connectionProfile.id }; this._actionBar.context = context; this.exportActionItem.enabled = false; + this.generateReportActionItem.enabled = false; } private convertToDataViewItems(asmtResult: azdata.SqlAssessmentResultItem, index: number, method: AssessmentType) { @@ -392,7 +411,7 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom this._table.autosizeColumns(); this._table.resizeCanvas(); this.exportActionItem.enabled = (results.length > 0 && method === AssessmentType.InvokeAssessment); - + this.generateReportActionItem.enabled = (results.length > 0 && method === AssessmentType.InvokeAssessment); if (results.length > 0) { dom.hide(this.placeholderElem); } else { @@ -472,7 +491,7 @@ export class AsmtResultsViewComponent extends TabChild implements IAssessmentCom 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 `${wrapByKindFunc(kind, escape(msg))}${LocalizedStrings.LEARN_MORE_LINK}`; } return undefined; } diff --git a/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts b/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts index 18dbd0f5b9..4629e3a62e 100644 --- a/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts +++ b/src/sql/workbench/contrib/assessment/browser/asmtView.component.ts @@ -4,25 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./media/asmt'; -import { Component, Inject, forwardRef, ChangeDetectorRef, ViewChild, Injectable, OnInit } from '@angular/core'; +import { Component, Inject, forwardRef, ChangeDetectorRef, 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:") -}; +import { LocalizedStrings } from 'sql/workbench/contrib/assessment/common/strings'; export const DASHBOARD_SELECTOR: string = 'asmtview-component'; @@ -33,7 +19,6 @@ export const DASHBOARD_SELECTOR: string = 'asmtview-component'; @Injectable() export class AsmtViewComponent extends AngularDisposable implements OnInit { - @ViewChild('asmtresultcomponent') private _asmtResultView: AsmtResultsViewComponent; protected localizedStrings = LocalizedStrings; connectionInfo: ServerInfo = null; @@ -41,9 +26,6 @@ export class AsmtViewComponent extends AngularDisposable implements OnInit { ruleset: string = ''; api: string = ''; - - - constructor( @Inject(forwardRef(() => ChangeDetectorRef)) private _cd: ChangeDetectorRef, @Inject(forwardRef(() => CommonServiceInterface)) private _commonService: CommonServiceInterface) { @@ -57,13 +39,7 @@ export class AsmtViewComponent extends AngularDisposable implements OnInit { 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; - } + this.instanceName = serverName; } public displayAssessmentInfo(apiVersion: string, rulesetVersion: string) { @@ -71,9 +47,4 @@ export class AsmtViewComponent extends AngularDisposable implements OnInit { this.ruleset = rulesetVersion; this._cd.detectChanges(); } - - public layout() { - this._asmtResultView.layout(); - //this._panel.layout(); - } } diff --git a/src/sql/workbench/contrib/assessment/common/consts.ts b/src/sql/workbench/contrib/assessment/common/consts.ts index b28c754786..bde500df9d 100644 --- a/src/sql/workbench/contrib/assessment/common/consts.ts +++ b/src/sql/workbench/contrib/assessment/common/consts.ts @@ -10,7 +10,8 @@ export enum AssessmentTargetType { export enum AssessmentType { AvailableRules = 1, - InvokeAssessment = 2 + InvokeAssessment = 2, + ReportGeneration = 3 } export const TARGET_ICON_CLASS: { [targetType: number]: string } = { diff --git a/src/sql/workbench/contrib/assessment/common/htmlReportGenerator.ts b/src/sql/workbench/contrib/assessment/common/htmlReportGenerator.ts new file mode 100644 index 0000000000..d646f2e408 --- /dev/null +++ b/src/sql/workbench/contrib/assessment/common/htmlReportGenerator.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as platform from 'vs/base/common/platform'; +import { ConnectionManagementInfo } from 'sql/platform/connection/common/connectionManagementInfo'; +import { escape } from 'vs/base/common/strings'; +import { SqlAssessmentResult, SqlAssessmentResultItem } from 'azdata'; +import { SqlAssessmentTargetType } from 'sql/workbench/api/common/sqlExtHostTypes'; +import { LocalizedStrings } from 'sql/workbench/contrib/assessment/common/strings'; + +export class HTMLReportBuilder { + constructor( + private _assessmentResult: SqlAssessmentResult, + private _dateUpdated: number, + private _connectionInfo: ConnectionManagementInfo + ) { + } + + public build(): string { + let mainContent = ` + + + ${LocalizedStrings.REPORT_TITLE} + + +
+
${LocalizedStrings.REPORT_TITLE}
+
+
${new Date(this._dateUpdated).toLocaleString(platform.locale)}
+ ${this.buildVersionDetails()} +
+ ${this.buildResultsSection()} +
+ ${this.buildStyleSection()} + + `; + return mainContent; + } + + private instanceName(): string { + const serverName = this._connectionInfo.connectionProfile.serverName; + if (['local', '(local)', '(local);'].indexOf(serverName.toLowerCase()) >= 0) { + return this._connectionInfo.serverInfo !== undefined + ? this._connectionInfo.serverInfo['machineName'] + : serverName; + } + return serverName; + } + + private buildVersionDetails(): string { + return ` +
+
+ ${LocalizedStrings.API_VERSION} ${this._assessmentResult.apiVersion}
+ ${LocalizedStrings.DEFAULT_RULESET_VERSION} ${this._assessmentResult.items[0].rulesetVersion} +
+
+ ${LocalizedStrings.SECTION_TITLE_SQL_SERVER}: ${this._connectionInfo.serverInfo?.serverEdition} ${this._connectionInfo.serverInfo?.serverVersion}
+ ${LocalizedStrings.SERVER_INSTANCENAME} ${this.instanceName()} +
+
+ `; + } + + private buildResultsSection(): string { + let resultByTarget = []; + this._assessmentResult.items.forEach(resultItem => { + if (resultByTarget[resultItem.targetType] === undefined) { + resultByTarget[resultItem.targetType] = []; + } + if (resultByTarget[resultItem.targetType][resultItem.targetName] === undefined) { + resultByTarget[resultItem.targetType][resultItem.targetName] = []; + } + resultByTarget[resultItem.targetType][resultItem.targetName].push(resultItem); + }); + + let result = ''; + if (resultByTarget[SqlAssessmentTargetType.Server] !== undefined) { + Object.keys(resultByTarget[SqlAssessmentTargetType.Server]).forEach(instanceName => { + result += this.buildTargetAssessmentSection(resultByTarget[SqlAssessmentTargetType.Server][instanceName]); + }); + } + if (resultByTarget[SqlAssessmentTargetType.Database] !== undefined) { + Object.keys(resultByTarget[SqlAssessmentTargetType.Database]).forEach(dbName => { + result += this.buildTargetAssessmentSection(resultByTarget[SqlAssessmentTargetType.Database][dbName]); + }); + + } + + return result; + } + + private buildTargetAssessmentSection(targetResults: SqlAssessmentResultItem[]): string { + let content = ` +
+
${targetResults[0].targetType === SqlAssessmentTargetType.Server ? LocalizedStrings.RESULTS_FOR_INSTANCE : LocalizedStrings.RESULTS_FOR_DATABASE}: ${targetResults[0].targetName}
+ ${this.buildSeveritySection(LocalizedStrings.REPORT_ERROR, targetResults.filter(item => item.level === 'Error'))} + ${this.buildSeveritySection(LocalizedStrings.REPORT_WARNING, targetResults.filter(item => item.level === 'Warning'))} + ${this.buildSeveritySection(LocalizedStrings.REPORT_INFO, targetResults.filter(item => item.level === 'Information'))} +
`; + return content; + } + private buildSeveritySection(severityName: string, items: SqlAssessmentResultItem[]) { + if (items.length === 0) { + return ''; + } + + return ` +
+
${LocalizedStrings.REPORT_SEVERITY_MESSAGE(severityName, items.length)}
+ + + ${this.buildItemsRows(items)} +
${LocalizedStrings.MESSAGE_COLUMN_NAME}${LocalizedStrings.HELP_LINK_COLUMN_NAME}${LocalizedStrings.TAGS_COLUMN_NAME}${LocalizedStrings.CHECKID_COLUMN_NAME}
+
`; + } + private buildItemsRows(items: SqlAssessmentResultItem[]): string { + let content = ''; + items.forEach(item => { + content += ` + ${escape(item.message)} + ${LocalizedStrings.LEARN_MORE_LINK} + ${this.formatTags(item.tags)} + ${item.checkId} + `; + }); + return content; + } + private formatTags(tags: string[]): string { + return tags?.join(', '); + } + + private buildStyleSection(): string { + return ` + + `; + } +} diff --git a/src/sql/workbench/contrib/assessment/common/strings.ts b/src/sql/workbench/contrib/assessment/common/strings.ts new file mode 100644 index 0000000000..444412365c --- /dev/null +++ b/src/sql/workbench/contrib/assessment/common/strings.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export 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:"), + MESSAGE_COLUMN_NAME: localize('asmt.column.message', "Message"), + CHECKID_COLUMN_NAME: localize('asmt.column.checkId', "Check ID"), + TAGS_COLUMN_NAME: localize('asmt.column.tags', "Tags"), + LEARN_MORE_LINK: localize('asmt.learnMore', "Learn More"), + REPORT_TITLE: localize('asmt.sqlReportTitle', "SQL Assessment Report"), + RESULTS_FOR_DATABASE: localize('asmt.sqlReport.resultForDatabase', "Results for database"), + RESULTS_FOR_INSTANCE: localize('asmt.sqlReport.resultForInstance', "Results for server"), + REPORT_ERROR: localize('asmt.sqlReport.Error', "Error"), + REPORT_WARNING: localize('asmt.sqlReport.Warning', "Warning"), + REPORT_INFO: localize('asmt.sqlReport.Info', "Information"), + HELP_LINK_COLUMN_NAME: localize('asmt.column.helpLink', "Help Link"), + REPORT_SEVERITY_MESSAGE: function (severity: string, count: number) { + return localize('asmt.sqlReport.severityMsg', "{0}: {1} item(s)", severity, count); + }, +}; diff --git a/src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts b/src/sql/workbench/contrib/assessment/test/browser/asmtActions.test.ts similarity index 77% rename from src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts rename to src/sql/workbench/contrib/assessment/test/browser/asmtActions.test.ts index 35b54bbcc5..cef25c43f5 100644 --- a/src/sql/workbench/contrib/assessment/test/common/asmtActions.test.ts +++ b/src/sql/workbench/contrib/assessment/test/browser/asmtActions.test.ts @@ -8,13 +8,15 @@ import * as assert from 'assert'; import { AssessmentType, AssessmentTargetType } from 'sql/workbench/contrib/assessment/common/consts'; import { IAssessmentComponent, + SqlAssessmentResultInfo, AsmtServerInvokeItemsAction, AsmtServerSelectItemsAction, AsmtExportAsScriptAction, AsmtSamplesLinkAction, AsmtDatabaseInvokeItemsAction, - AsmtDatabaseSelectItemsAction -} from 'sql/workbench/contrib/assessment/common/asmtActions'; + AsmtDatabaseSelectItemsAction, + AsmtGenerateHTMLReportAction +} from 'sql/workbench/contrib/assessment/browser/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'; @@ -23,28 +25,44 @@ import { TestConnectionManagementService } from 'sql/platform/connection/test/co import { NullLogService } from 'vs/platform/log/common/log'; import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; import { OpenerServiceStub } from 'sql/platform/opener/common/openerServiceStub'; +import { SqlAssessmentTargetType } from 'sql/workbench/api/common/sqlExtHostTypes'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { TestFileService, TestEnvironmentService, TestFileDialogService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { TestNotificationService } from 'vs/platform/notification/test/common/testNotificationService'; +import { URI } from 'vs/base/common/uri'; +import { IFileService } from 'vs/platform/files/common/files'; /** * Class to test Assessment Management Actions */ - let assessmentResultItems: azdata.SqlAssessmentResultItem[] = [ - { checkId: 'check1' }, - { checkId: 'check2' }, - { checkId: 'check3' } + { + checkId: 'check1', + rulesetVersion: '1.0.0', + targetType: SqlAssessmentTargetType.Database, + targetName: 'db1', + level: 'Warning', + message: '', + tags: ['tag2'], + }, + { + checkId: 'check2', + rulesetVersion: '1.0.0', + targetType: SqlAssessmentTargetType.Database, + targetName: 'db1', + level: 'Error', + message: '', + tags: ['tag1', 'tag2'] + }, + { + checkId: 'check3', + rulesetVersion: '1.0.0', + targetType: SqlAssessmentTargetType.Database, + targetName: 'db1', + level: 'Information', + message: '', + tags: ['tag3', 'tag4'] + } ]; - -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: '', @@ -52,6 +70,28 @@ let assessmentResult: azdata.SqlAssessmentResult = { items: assessmentResultItems }; +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; } + recentResult: SqlAssessmentResultInfo = { + result: assessmentResult, + connectionInfo: { + serverInfo: null, + connectionProfile: { + serverName: 'testServer' + } + }, + dateUpdated: Date.now() + }; + isActive: boolean = true; +} + +let mockAssessmentService: TypeMoq.Mock; +let mockAsmtViewComponent: TypeMoq.Mock; + + // Tests suite('Assessment Actions', () => { @@ -181,4 +221,46 @@ suite('Assessment Actions', () => { openerService.verify(s => s.open(TypeMoq.It.isAny()), TypeMoq.Times.once()); }); + test('Make HTML Report Action', async () => { + const openerService = TypeMoq.Mock.ofType(OpenerServiceStub); + openerService.setup(s => s.open(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(true)); + + const fileUri = URI.file('/user/home'); + const fileDialogService = new TestFileDialogService(); + fileDialogService.setPickFileToSave(fileUri); + + const notificationService = TypeMoq.Mock.ofType(TestNotificationService); + notificationService.setup(s => s.prompt(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => null); + + const fileService = TypeMoq.Mock.ofType(TestFileService); + fileService.setup(s => s.createFile(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => { + return Promise.resolve({ + ctime: Date.now(), + etag: 'index.txt', + isFile: true, + isDirectory: false, + isSymbolicLink: false, + mtime: Date.now(), + name: '', + resource: fileUri, + size: 42 + }); + }); + + + + let action = new AsmtGenerateHTMLReportAction(fileService.object, + openerService.object, + TestEnvironmentService, + new NullAdsTelemetryService(), + notificationService.object, + fileDialogService); + assert.equal(action.id, AsmtGenerateHTMLReportAction.ID, 'Generate HTML Report id action mismatch'); + assert.equal(action.label, AsmtGenerateHTMLReportAction.LABEL, 'Generate HTML Report label action mismatch'); + + let result = await action.run({ ownerUri: '', component: mockAsmtViewComponent.object, connectionId: '' }); + assert.ok(result, 'Generate HTML Report action should succeed'); + notificationService.verify(s => s.prompt(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); }); + 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 91410b163d..1619560ec8 100644 --- a/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.ts +++ b/src/sql/workbench/contrib/dashboard/browser/contents/controlHostContent.component.ts @@ -13,7 +13,6 @@ 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')), @@ -31,7 +30,6 @@ export class ControlHostContent { /* Children components */ @ViewChild('agent') private _agentViewComponent: AgentViewComponent; - @ViewChild('asmt') private _asmtViewComponent: AsmtViewComponent; constructor( @Inject(forwardRef(() => CommonServiceInterface)) private _dashboardService: CommonServiceInterface, @@ -41,7 +39,6 @@ export class ControlHostContent { public layout(): void { this._agentViewComponent?.layout(); - this._asmtViewComponent?.layout(); } public get id(): string {