diff --git a/extensions/sql-assessment/package.json b/extensions/sql-assessment/package.json index be2f538853..85111598c1 100644 --- a/extensions/sql-assessment/package.json +++ b/extensions/sql-assessment/package.json @@ -32,15 +32,15 @@ "title": "%displayName%", "when": "connectionProvider == 'MSSQL' && !mssql:iscloud && mssql:engineedition != 11", "container": { - "controlhost-container": { - "type": "assessment" - } + "modelview-container": null } } ] }, "dependencies": { - "vscode-nls": "^3.2.1" + "vscode-nls": "^3.2.1", + "ads-extension-telemetry": "github:Charles-Gagnon/ads-extension-telemetry#0.1.0", + "vscode-languageclient": "^5.3.0-next.1" }, "__metadata": { "id": "67", diff --git a/extensions/sql-assessment/resources/dark/configuredashboard_inverse.svg b/extensions/sql-assessment/resources/dark/configuredashboard_inverse.svg new file mode 100644 index 0000000000..d217190556 --- /dev/null +++ b/extensions/sql-assessment/resources/dark/configuredashboard_inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-assessment/resources/dark/database.svg b/extensions/sql-assessment/resources/dark/database.svg new file mode 100644 index 0000000000..60fcdb4136 --- /dev/null +++ b/extensions/sql-assessment/resources/dark/database.svg @@ -0,0 +1 @@ +database_16x \ No newline at end of file diff --git a/extensions/sql-assessment/resources/dark/history.svg b/extensions/sql-assessment/resources/dark/history.svg new file mode 100644 index 0000000000..69d5c3b637 --- /dev/null +++ b/extensions/sql-assessment/resources/dark/history.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/extensions/sql-assessment/resources/dark/newquery_inverse.svg b/extensions/sql-assessment/resources/dark/newquery_inverse.svg new file mode 100644 index 0000000000..5e52f63628 --- /dev/null +++ b/extensions/sql-assessment/resources/dark/newquery_inverse.svg @@ -0,0 +1 @@ +newquery_inverse_16x16 \ No newline at end of file diff --git a/extensions/sql-assessment/resources/dark/server.svg b/extensions/sql-assessment/resources/dark/server.svg new file mode 100644 index 0000000000..9134ea199a --- /dev/null +++ b/extensions/sql-assessment/resources/dark/server.svg @@ -0,0 +1 @@ + diff --git a/extensions/sql-assessment/resources/light/configuredashboard.svg b/extensions/sql-assessment/resources/light/configuredashboard.svg new file mode 100644 index 0000000000..f282b34c57 --- /dev/null +++ b/extensions/sql-assessment/resources/light/configuredashboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/sql-assessment/resources/light/database.svg b/extensions/sql-assessment/resources/light/database.svg new file mode 100644 index 0000000000..60fcdb4136 --- /dev/null +++ b/extensions/sql-assessment/resources/light/database.svg @@ -0,0 +1 @@ +database_16x \ No newline at end of file diff --git a/extensions/sql-assessment/resources/light/history.svg b/extensions/sql-assessment/resources/light/history.svg new file mode 100644 index 0000000000..402a6c6fc9 --- /dev/null +++ b/extensions/sql-assessment/resources/light/history.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/extensions/sql-assessment/resources/light/newquery.svg b/extensions/sql-assessment/resources/light/newquery.svg new file mode 100644 index 0000000000..e783cf3958 --- /dev/null +++ b/extensions/sql-assessment/resources/light/newquery.svg @@ -0,0 +1 @@ +newquery_16x16 \ No newline at end of file diff --git a/extensions/sql-assessment/resources/light/server.svg b/extensions/sql-assessment/resources/light/server.svg new file mode 100644 index 0000000000..f5607d0677 --- /dev/null +++ b/extensions/sql-assessment/resources/light/server.svg @@ -0,0 +1 @@ + diff --git a/extensions/sql-assessment/src/assessmentResultGrid.ts b/extensions/sql-assessment/src/assessmentResultGrid.ts new file mode 100644 index 0000000000..3aceb510b8 --- /dev/null +++ b/extensions/sql-assessment/src/assessmentResultGrid.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; + +import { AssessmentType } from './engine'; +import { LocalizedStrings } from './localized'; +const localize = nls.loadMessageBundle(); + +export class AssessmentResultGrid implements vscode.Disposable { + + private table!: azdata.TableComponent; + private rootContainer!: azdata.FlexContainer; + private toDispose: vscode.Disposable[] = []; + private detailsPanel!: azdata.FlexContainer; + private dataItems!: azdata.SqlAssessmentResultItem[]; + + private tagsPlaceholder!: azdata.TextComponent; + private checkNamePlaceholder!: azdata.TextComponent; + private checkDescriptionPlaceholder!: azdata.TextComponent; + private clickHereLabel!: azdata.HyperlinkComponent; + private asmtMessagePlaceholder!: azdata.TextComponent; + private asmtMessageDiv!: azdata.DivContainer; + private descriptionCaption!: azdata.TextComponent; + + private asmtType!: AssessmentType; + + private readonly checkIdColOrder = 4; + private readonly targetColOrder = 0; + + public get component(): azdata.Component { + return this.rootContainer; + } + + public constructor(view: azdata.ModelView) { + const headerCssClass = 'no-borders align-with-header'; + this.table = view.modelBuilder.table() + .withProperties({ + data: [], + columns: [ + { value: LocalizedStrings.TARGET_COLUMN_NAME, headerCssClass: headerCssClass, width: 125 }, + { value: LocalizedStrings.SEVERITY_COLUMN_NAME, headerCssClass: headerCssClass, width: 100 }, + { value: LocalizedStrings.MESSAGE_COLUMN_NAME, headerCssClass: headerCssClass, width: 900 }, + { value: LocalizedStrings.TAGS_COLUMN_NAME, headerCssClass: headerCssClass, width: 200 }, + { value: LocalizedStrings.CHECKID_COLUMN_NAME, headerCssClass: headerCssClass, width: 80 } + ], + width: '100%', + height: '100px', + headerFilter: true + }).component(); + + + this.toDispose.push( + this.table.onRowSelected(async () => { + if (this.table.selectedRows?.length !== 1) { + return; + } + await this.showDetails(this.table.selectedRows[0]); + })); + + + this.rootContainer = view.modelBuilder.flexContainer() + .withItems([this.table], { + flex: '1 1 auto', + order: 1 + }) + .withLayout( + { + flexFlow: 'column', + height: '100%', + }) + .component(); + + this.detailsPanel = this.createDetailsPanel(view); + + this.rootContainer.addItem(this.detailsPanel, { + flex: '0 0 200px', + order: 2, + CSSStyles: { + 'padding-bottom': '15px', + 'visibility': 'hidden' + } + }); + } + + dispose() { + this.toDispose.forEach(disposable => disposable.dispose()); + } + + public async displayResult(asmtResult: azdata.SqlAssessmentResult, method: AssessmentType) { + this.asmtType = method; + this.dataItems = asmtResult.items; + await this.table.updateProperties({ + 'data': asmtResult.items.map(item => this.convertToDataView(item)) + }); + this.rootContainer.setLayout({ + flexFlow: 'column', + height: '100%', + }); + this.rootContainer.setItemLayout(this.table, { + flex: '1 1 auto', + CSSStyles: { + 'height': '100%' + } + }); + + await this.table.updateProperties({ + 'height': '100%' + }); + + this.detailsPanel.updateCssStyles({ + 'visibility': 'hidden' + }); + } + + public async appendResult(asmtResult: azdata.SqlAssessmentResult): Promise { + if (this.dataItems) { + this.dataItems.push(...asmtResult.items); + } + + await this.table.updateProperties({ + 'data': this.dataItems.map(item => this.convertToDataView(item)) + }); + } + + private async showDetails(rowNumber: number) { + const selectedRowValues = this.table.data[rowNumber]; + const asmtResultItem = this.dataItems.find(item => + item.targetName === selectedRowValues[this.targetColOrder] + && item.checkId === selectedRowValues[this.checkIdColOrder]); + if (!asmtResultItem) { + return; + } + this.checkNamePlaceholder.value = asmtResultItem.displayName; + this.checkDescriptionPlaceholder.value = asmtResultItem.description; + this.clickHereLabel.url = asmtResultItem.helpLink; + this.tagsPlaceholder.value = asmtResultItem.tags?.join(', '); + this.asmtMessagePlaceholder.value = asmtResultItem.message; + + if (this.asmtType === AssessmentType.InvokeAssessment) { + this.asmtMessageDiv.display = 'block'; + this.descriptionCaption.display = 'block'; + } else { + this.asmtMessageDiv.display = 'none'; + this.descriptionCaption.display = 'none'; + } + + this.detailsPanel.updateCssStyles({ + 'visibility': 'visible' + }); + } + + + + private createDetailsPanel(view: azdata.ModelView): azdata.FlexContainer { + + const root = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + height: '200px', + }).withProperties({ + CSSStyles: { + 'padding': '20px', + 'border-top': '3px solid rgb(221, 221, 221)' + } + }).component(); + const cssNoMarginFloatLeft = { 'margin': '0px', 'float': 'left' }; + const cssBlockCaption = { 'font-weight': 'bold', 'margin': '0px', 'display': 'block', 'padding-top': '5px' }; + const flexSettings = '0 1 auto'; + + + this.checkNamePlaceholder = view.modelBuilder.text().withProperties({ + CSSStyles: { ...cssNoMarginFloatLeft, 'font-weight': 'bold', 'font-size': '16px', 'padding-bottom': '5px', 'display': 'block' } + }).component(); + this.checkDescriptionPlaceholder = view.modelBuilder.text().withProperties({ + CSSStyles: { ...cssNoMarginFloatLeft, 'padding-right': '2px' } + }).component(); + this.clickHereLabel = view.modelBuilder.hyperlink().withProperties({ + label: localize('asmt.details.clickHere', "Click here"), + url: '', + CSSStyles: cssNoMarginFloatLeft + }).component(); + const toLearnMoreText = view.modelBuilder.text().withProperties({ + CSSStyles: { ...cssNoMarginFloatLeft, 'padding-left': '2px' }, + value: localize('asmt.details.toLearnMore', " to learn more.") + }).component(); + const tagsCaption = view.modelBuilder.text().withProperties({ + CSSStyles: cssBlockCaption, + value: LocalizedStrings.TAGS_COLUMN_NAME + }).component(); + this.tagsPlaceholder = view.modelBuilder.text().withProperties({ + CSSStyles: cssNoMarginFloatLeft + }).component(); + + this.asmtMessagePlaceholder = view.modelBuilder.text().withProperties({ + CSSStyles: cssNoMarginFloatLeft + }).component(); + + this.descriptionCaption = view.modelBuilder.text().withProperties({ + CSSStyles: cssBlockCaption, + value: localize('asmt.details.ruleDescription', "Rule Description") + }).component(); + + root.addItem( + this.checkNamePlaceholder, { flex: flexSettings } + ); + + this.asmtMessageDiv = view.modelBuilder.divContainer().withItems([ + view.modelBuilder.text().withProperties({ + CSSStyles: cssBlockCaption, + value: localize('asmt.details.recommendation', "Recommendation") + }).component(), + this.asmtMessagePlaceholder + ]).component(); + + root.addItem( + this.asmtMessageDiv, + { flex: flexSettings } + ); + + + root.addItem( + view.modelBuilder.divContainer().withItems([ + this.descriptionCaption, + this.checkDescriptionPlaceholder, + this.clickHereLabel, + toLearnMoreText + ]).component(), + { flex: flexSettings } + ); + + root.addItem( + view.modelBuilder.divContainer().withItems([ + tagsCaption, + this.tagsPlaceholder + ]).component(), + { flex: flexSettings } + ); + + return root; + } + private clearOutDefaultRuleset(tags: string[]): string[] { + let idx = tags.findIndex(item => item.toUpperCase() === 'DEFAULTRULESET'); + if (idx > -1) { + tags.splice(idx, 1); + } + return tags; + } + + private convertToDataView(asmtResult: azdata.SqlAssessmentResultItem): any[] { + return [ + asmtResult.targetName, + asmtResult.level, + this.asmtType === AssessmentType.InvokeAssessment ? asmtResult.message : asmtResult.displayName, + this.clearOutDefaultRuleset(asmtResult.tags), + asmtResult.checkId + ]; + } +} diff --git a/extensions/sql-assessment/src/engine.ts b/extensions/sql-assessment/src/engine.ts new file mode 100644 index 0000000000..92f2a0bcac --- /dev/null +++ b/extensions/sql-assessment/src/engine.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 mssql from '../../mssql'; +import * as azdata from 'azdata'; +import { createHistoryFileName, readHistoryFileNames, getAssessmentDate, TargetWithChildren } from './utils'; +import { promises as fs } from 'fs'; +import { TelemetryReporter, SqlAssessmentTelemetryView, SqlTelemetryActions } from './telemetry'; + +export enum AssessmentType { + AvailableRules = 1, + InvokeAssessment = 2 +} + +export type OnResultCallback = (result: azdata.SqlAssessmentResult, assessmentType: AssessmentType, append: boolean) => void; + +export interface SqlAssessmentRecord { + result: azdata.SqlAssessmentResult; + dateUpdated: number; +} + + +interface SqlAssessmentResultInfo extends SqlAssessmentRecord { + connectionInfo: azdata.connection.ConnectionProfile +} + + +export class AssessmentEngine { + private sqlAssessment!: mssql.ISqlAssessmentService; + private connectionUri: string = ''; + private connectionProfile!: azdata.connection.ConnectionProfile; + private lastInvokedResults!: SqlAssessmentResultInfo; + private historicalRecords!: SqlAssessmentRecord[]; + + + constructor(service: mssql.ISqlAssessmentService) { + this.sqlAssessment = service; + } + + public get isServerConnection(): boolean { + return !this.connectionProfile.databaseName || this.connectionProfile.databaseName === 'master'; + } + public get databaseName(): string { + return this.connectionProfile.databaseName; + } + public get recentResult(): SqlAssessmentResultInfo { + return this.lastInvokedResults; + } + public get targetName(): string { + return this.isServerConnection ? this.connectionProfile.serverName : this.connectionProfile.databaseName; + } + + + public async initialize(connectionId: string) { + this.connectionUri = await azdata.connection.getUriForConnection(connectionId); + this.connectionProfile = await azdata.connection.getCurrentConnection(); + } + + public async performAssessment(asmtType: AssessmentType, onResult: OnResultCallback): Promise { + if (this.isServerConnection) { + await this.performServerAssessment(asmtType, onResult); + } else { + if (asmtType === AssessmentType.AvailableRules) { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.GetDatabaseAssessmentRules); + await onResult(await this.sqlAssessment.getAssessmentItems(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Database), asmtType, false); + } else { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.InvokeDatabaseAssessment); + const result = await this.sqlAssessment.assessmentInvoke(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Database); + + this.lastInvokedResults = { + connectionInfo: this.connectionProfile, + dateUpdated: Date.now(), + result: result + }; + + await onResult(result, asmtType, false); + + this.saveAssessment(this.databaseName, result); + } + } + + if (asmtType === AssessmentType.InvokeAssessment && this.historicalRecords !== undefined) { + this.historicalRecords.push({ + result: this.lastInvokedResults.result, + dateUpdated: this.lastInvokedResults.dateUpdated + }); + } + } + + public generateAssessmentScript(): Promise { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.ExportAssessmentResults); + return this.sqlAssessment.generateAssessmentScript(this.lastInvokedResults.result.items, '', '', azdata.TaskExecutionMode.script); + } + + public async readHistory(): Promise { + if (this.historicalRecords === undefined) { + await this.loadHistory(); + } + + return this.historicalRecords; + } + + private async loadHistory(): Promise { + this.historicalRecords = []; + const files: TargetWithChildren[] = await readHistoryFileNames(this.targetName); + + for (let nFileName = 0; nFileName < files.length; nFileName++) { + const file: TargetWithChildren = files[nFileName]; + const content: string = await fs.readFile(file.target, 'utf8'); + const result: azdata.SqlAssessmentResult = JSON.parse(content); + + if (this.isServerConnection) { + for (let nChild = 0; nChild < file.children.length; nChild++) { + const childResult: azdata.SqlAssessmentResult = JSON.parse(await fs.readFile(file.children[nChild], 'utf8')); + result.items.push(...childResult.items); + } + } + + const date = getAssessmentDate(file.target); + + this.historicalRecords.push({ + dateUpdated: date, + result: result + }); + } + } + + private async performServerAssessment(asmtType: AssessmentType, onResult: OnResultCallback): Promise { + let databaseListRequest = azdata.connection.listDatabases(this.connectionProfile.connectionId); + + let assessmentResult: azdata.SqlAssessmentResult; + if (AssessmentType.InvokeAssessment) { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.InvokeServerAssessment); + assessmentResult = await this.sqlAssessment.assessmentInvoke(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Server); + + this.lastInvokedResults = { + connectionInfo: this.connectionProfile, + dateUpdated: Date.now(), + result: assessmentResult + }; + this.saveAssessment(this.connectionProfile.serverName, assessmentResult); + } else { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.GetServerAssessmentRules); + assessmentResult = await this.sqlAssessment.getAssessmentItems(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Server); + } + + await onResult(assessmentResult, asmtType, false); + + let connectionProvider = azdata.dataprotocol.getProvider( + this.connectionProfile.providerId, azdata.DataProviderType.ConnectionProvider); + + const dbList = await databaseListRequest; + + for (let nDbName = 0; nDbName < dbList.length; nDbName++) { + const db = dbList[nDbName]; + + if (await connectionProvider.changeDatabase(this.connectionUri, db)) { + let assessmentResult = asmtType === AssessmentType.InvokeAssessment + ? await this.sqlAssessment.assessmentInvoke(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Database) + : await this.sqlAssessment.getAssessmentItems(this.connectionUri, azdata.sqlAssessment.SqlAssessmentTargetType.Database); + + if (assessmentResult?.items) { + if (asmtType === AssessmentType.InvokeAssessment) { + this.lastInvokedResults.result.items.push(...assessmentResult?.items); + this.saveAssessment(db, assessmentResult); + } + await onResult(assessmentResult, asmtType, true); + } + } + } + } + + private async saveAssessment(target: string, assessment: azdata.SqlAssessmentResult): Promise { + try { + const fileName = await createHistoryFileName(target, this.lastInvokedResults.dateUpdated); + return fs.writeFile(fileName, JSON.stringify(assessment)); + } + catch (err) { + console.error(`error saving sql assessment history file: ${err}`); + } + } +} diff --git a/extensions/sql-assessment/src/htmlReportGenerator.ts b/extensions/sql-assessment/src/htmlReportGenerator.ts new file mode 100644 index 0000000000..dab6e99b5e --- /dev/null +++ b/extensions/sql-assessment/src/htmlReportGenerator.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { LocalizedStrings } from './localized'; +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import { htmlEscape } from './utils'; + +export class HTMLReportBuilder { + constructor( + private _assessmentResult: azdata.SqlAssessmentResult, + private _dateUpdated: number, + private _connectionInfo: azdata.connection.ConnectionProfile + ) { + } + + public async build(): Promise { + const serverInfo = await azdata.connection.getServerInfo(this._connectionInfo.connectionId); + + let mainContent = ` + + + ${LocalizedStrings.REPORT_TITLE} + + +
+
${LocalizedStrings.REPORT_TITLE}
+
+
${new Date(this._dateUpdated).toLocaleString(vscode.env.language)}
+ ${this.buildVersionDetails(serverInfo)} +
+ ${this.buildResultsSection()} +
+ ${this.buildStyleSection()} + + `; + return mainContent; + } + + private instanceName(serverInfo: azdata.ServerInfo): string { + const serverName = this._connectionInfo.serverName; + if (['local', '(local)'].indexOf(serverName.toLowerCase()) >= 0) { + + return serverInfo !== undefined + ? (serverInfo)['machineName'] + : serverName; + } + return serverName; + } + + private buildVersionDetails(serverInfo: azdata.ServerInfo): string { + return ` +
+
+ ${LocalizedStrings.API_VERSION}: ${this._assessmentResult.apiVersion}
+ ${LocalizedStrings.DEFAULT_RULESET_VERSION}: ${this._assessmentResult.items[0].rulesetVersion} +
+
+ ${LocalizedStrings.SECTION_TITLE_SQL_SERVER}: ${serverInfo.serverEdition} ${serverInfo.serverVersion}
+ ${LocalizedStrings.SERVER_INSTANCENAME}: ${this.instanceName(serverInfo)} +
+
+ `; + } + + private buildResultsSection(): string { + let resultByTarget: { [targetType: number]: { [targetName: string]: azdata.SqlAssessmentResultItem[] } } = []; + this._assessmentResult.items.forEach(resultItem => { + if (resultByTarget[resultItem.targetType] === undefined) { + resultByTarget[resultItem.targetType] = Object.create([]); + } + if (resultByTarget[resultItem.targetType][resultItem.targetName] === undefined) { + resultByTarget[resultItem.targetType][resultItem.targetName] = []; + } + resultByTarget[resultItem.targetType][resultItem.targetName].push(resultItem); + }); + + let result = ''; + if (resultByTarget[azdata.sqlAssessment.SqlAssessmentTargetType.Server] !== undefined) { + Object.keys(resultByTarget[azdata.sqlAssessment.SqlAssessmentTargetType.Server]).forEach(instanceName => { + result += this.buildTargetAssessmentSection(resultByTarget[azdata.sqlAssessment.SqlAssessmentTargetType.Server][instanceName]); + }); + } + if (resultByTarget[azdata.sqlAssessment.SqlAssessmentTargetType.Database] !== undefined) { + Object.keys(resultByTarget[azdata.sqlAssessment.SqlAssessmentTargetType.Database]).forEach(dbName => { + result += this.buildTargetAssessmentSection(resultByTarget[azdata.sqlAssessment.SqlAssessmentTargetType.Database][dbName]); + }); + + } + + return result; + } + + private buildTargetAssessmentSection(targetResults: azdata.SqlAssessmentResultItem[]): string { + let content = ` +
+
${targetResults[0].targetType === azdata.sqlAssessment.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: azdata.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: azdata.SqlAssessmentResultItem[]): string { + let content = ''; + items.forEach(item => { + content += ` + ${htmlEscape(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/extensions/sql-assessment/src/localized.ts b/extensions/sql-assessment/src/localized.ts new file mode 100644 index 0000000000..9fa67b4052 --- /dev/null +++ b/extensions/sql-assessment/src/localized.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +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"), + 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"), + TARGET_COLUMN_NAME: localize('asmt.column.target', "Target"), + SEVERITY_COLUMN_NAME: localize('asmt.column.severity', "Severity"), + 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); + }, + ASSESSMENT_TAB_NAME: 'Assessment', + HISTORY_TAB_NAME: 'History' +}; diff --git a/extensions/sql-assessment/src/main.ts b/extensions/sql-assessment/src/main.ts index f54d4572cd..855cc07e62 100644 --- a/extensions/sql-assessment/src/main.ts +++ b/extensions/sql-assessment/src/main.ts @@ -5,9 +5,16 @@ import * as vscode from 'vscode'; -export function activate(_context: vscode.ExtensionContext) { +import MainController from './maincontroller'; + +let mainController: MainController; + +export function activate(context: vscode.ExtensionContext) { + mainController = new MainController(context); + mainController.activate(); } // this method is called when your extension is deactivated export function deactivate(): void { + mainController?.deactivate(); } diff --git a/extensions/sql-assessment/src/maincontroller.ts b/extensions/sql-assessment/src/maincontroller.ts new file mode 100644 index 0000000000..fb42836897 --- /dev/null +++ b/extensions/sql-assessment/src/maincontroller.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as mssql from '../../mssql'; +import { SqlAssessmentMainTab } from './tabs/assessmentMainTab'; +import { SqlAssessmentHistoryTab } from './tabs/historyTab'; +import { AssessmentEngine } from './engine'; +import { TelemetryReporter, SqlAssessmentTelemetryView } from './telemetry'; + +const tabName = 'data-management-asmt'; + +/** + * The main controller class that initializes the extension + */ +export default class MainController { + private extensionContext: vscode.ExtensionContext; + private sqlAssessment!: mssql.ISqlAssessmentService; + private toDispose: vscode.Disposable[] = []; + private engine!: AssessmentEngine; + + public constructor(context: vscode.ExtensionContext) { + this.extensionContext = context; + } + + public deactivate(): void { + this.toDispose.forEach(disposable => disposable.dispose()); + } + + public async activate(): Promise { + + this.sqlAssessment = ((await vscode.extensions.getExtension(mssql.extension.name)?.activate() as mssql.IExtension)).sqlAssessment; + this.engine = new AssessmentEngine(this.sqlAssessment); + this.registerModelViewProvider(); + TelemetryReporter.sendViewEvent(SqlAssessmentTelemetryView); + return true; + } + + private registerModelViewProvider(): void { + azdata.ui.registerModelViewProvider(tabName, async (view) => { + await this.engine.initialize(view.connection.connectionId); + const mainTab = await new SqlAssessmentMainTab(this.extensionContext, this.engine).Create(view); + this.toDispose.push(mainTab); + const historyTab = await new SqlAssessmentHistoryTab(this.extensionContext, this.engine).Create(view) as SqlAssessmentHistoryTab; + this.toDispose.push(historyTab); + const tabbedPanel = view.modelBuilder.tabbedPanel() + .withTabs([mainTab, historyTab]) + .withLayout({ showIcon: true, alwaysShowTabs: true }) + .component(); + this.toDispose.push(tabbedPanel.onTabChanged(async (id) => { + if (id === historyTab.id) { + await historyTab.refresh(); + } + })); + await view.initializeModel(tabbedPanel); + }); + } +} + diff --git a/extensions/sql-assessment/src/tabs/assessmentMainTab.ts b/extensions/sql-assessment/src/tabs/assessmentMainTab.ts new file mode 100644 index 0000000000..8a2cd70d88 --- /dev/null +++ b/extensions/sql-assessment/src/tabs/assessmentMainTab.ts @@ -0,0 +1,305 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { SqlAssessmentTab } from './sqlAssessmentTab'; +import { AssessmentEngine, AssessmentType } from '../engine'; +import { promises as fs } from 'fs'; +import { suggestReportFile } from '../utils'; +import { HTMLReportBuilder } from '../htmlReportGenerator'; +import { AssessmentResultGrid } from '../assessmentResultGrid'; +import { LocalizedStrings } from '../localized'; +import { TelemetryReporter, SqlAssessmentTelemetryView, SqlTelemetryActions } from '../telemetry'; + +const localize = nls.loadMessageBundle(); + +export class SqlAssessmentMainTab extends SqlAssessmentTab { + private assessmentPropertiesContainer!: azdata.PropertiesContainerComponent; + private apiVersionPropItem: azdata.PropertiesContainerItem; + private defaultRulesetPropItem: azdata.PropertiesContainerItem; + private invokeAssessmentLabel: string = localize('invokeAssessmentLabelServer', "Invoke assessment"); + private getItemsLabel: string = localize('getAssessmentItemsServer', "View applicable rules"); + private btnExportAsScript!: azdata.ButtonComponent; + private btnHTMLExport!: azdata.ButtonComponent; + + private engine: AssessmentEngine; + private toDispose: vscode.Disposable[] = []; + private resultGrid!: AssessmentResultGrid; + + + + public constructor(extensionContext: vscode.ExtensionContext, engine: AssessmentEngine) { + super(extensionContext, LocalizedStrings.ASSESSMENT_TAB_NAME, 'MainTab', { + dark: extensionContext.asAbsolutePath('resources/dark/server.svg'), + light: extensionContext.asAbsolutePath('resources/light/server.svg') + }); + this.apiVersionPropItem = { displayName: LocalizedStrings.API_VERSION, value: '' }; + this.defaultRulesetPropItem = { displayName: LocalizedStrings.DEFAULT_RULESET_VERSION, value: '' }; + this.engine = engine; + } + + public dispose() { + this.toDispose.forEach(disposable => disposable.dispose()); + } + + async tabContent(view: azdata.ModelView): Promise { + + if (!this.engine.isServerConnection) { + this.invokeAssessmentLabel = localize('invokeAssessmentLabelDatabase', "Invoke assessment for {0}", this.engine.databaseName); + this.getItemsLabel = localize('getAssessmentItemsDatabase', "View applicable rules for {0}", this.engine.databaseName); + } + else { + this.invokeAssessmentLabel = localize('invokeAssessmentLabelServer', "Invoke assessment"); + this.getItemsLabel = localize('getAssessmentItemsServer', "View applicable rules"); + } + + let rootContainer = view.modelBuilder.flexContainer().withLayout( + { + flexFlow: 'column', + width: '100%', + height: '100%', + + }).component(); + + rootContainer.addItem(await this.createPropertiesSection(view), { flex: '0 0 auto' }); + rootContainer.addItem(await this.createToolbar(view), { + flex: '0 0 auto', CSSStyles: { + 'border-top': '3px solid rgb(221, 221, 221)', + 'margin-top': '20px', + 'height': '32px' + } + }); + + this.resultGrid = new AssessmentResultGrid(view); + rootContainer.addItem(this.resultGrid.component, { + flex: '1 1 auto', + CSSStyles: { + 'padding-bottom': '15px' + } + }); + + return rootContainer; + } + + private async createPropertiesSection(view: azdata.ModelView): Promise { + const serverInfo = await azdata.connection.getServerInfo(view.connection.connectionId); + const connectionProfile = await azdata.connection.getCurrentConnection(); + + const propertiesContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + justifyContent: 'flex-start' + }).component(); + + const apiInformationContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + alignContent: 'flex-start' + }).component(); + apiInformationContainer.addItem( + view.modelBuilder.text().withProperties({ value: LocalizedStrings.SECTION_TITLE_API }).component(), { + CSSStyles: { 'font-size': 'larger' } + }); + + this.assessmentPropertiesContainer = view.modelBuilder.propertiesContainer() + .withProperties({ + propertyItems: [ + this.apiVersionPropItem, + this.defaultRulesetPropItem] + }).component(); + + apiInformationContainer.addItem(this.assessmentPropertiesContainer, { + CSSStyles: { + 'margin-left': '20px' + } + }); + + const sqlServerContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'column', + alignContent: 'flex-start' + }).component(); + sqlServerContainer.addItem( + view.modelBuilder.text().withProperties({ value: LocalizedStrings.SECTION_TITLE_SQL_SERVER }).component(), { + CSSStyles: { 'font-size': 'larger' } + }); + sqlServerContainer.addItem( + view.modelBuilder.propertiesContainer() + .withProperties({ + propertyItems: [ + { displayName: LocalizedStrings.SERVER_VERSION, value: serverInfo.serverVersion }, + { displayName: LocalizedStrings.SERVER_INSTANCENAME, value: connectionProfile.serverName }, + { displayName: LocalizedStrings.SERVER_EDITION, value: serverInfo.serverEdition }, + { displayName: LocalizedStrings.SERVER_OSVERSION, value: serverInfo.osVersion }, + ] + }).component(), { + CSSStyles: { + 'margin-left': '20px' + } + }); + + propertiesContainer.addItem(apiInformationContainer, { flex: '0 0 300px', CSSStyles: { 'margin-left': '10px' } }); + propertiesContainer.addItem(sqlServerContainer, { flex: '1 1 auto' }); + + return propertiesContainer; + } + + private async createToolbar(view: azdata.ModelView): Promise { + + const targetIconPath = this.engine.isServerConnection + ? { + dark: this.extensionContext.asAbsolutePath('resources/dark/server.svg'), + light: this.extensionContext.asAbsolutePath('resources/light/server.svg') + } : { + dark: this.extensionContext.asAbsolutePath('resources/dark/database.svg'), + light: this.extensionContext.asAbsolutePath('resources/light/database.svg') + }; + + const btnInvokeAssessment = view.modelBuilder.button() + .withProperties({ + label: this.invokeAssessmentLabel, + iconPath: targetIconPath, + }).component(); + const btnInvokeAssessmentLoading = view.modelBuilder.loadingComponent() + .withItem(btnInvokeAssessment) + .withProperties({ + loadingText: this.invokeAssessmentLabel, + showText: true, + loading: false + }).component(); + + this.toDispose.push(btnInvokeAssessment.onDidClick(async () => { + btnInvokeAssessmentLoading.loading = true; + try { + await this.engine.performAssessment(AssessmentType.InvokeAssessment, + async (result: azdata.SqlAssessmentResult, assessmentType: AssessmentType, append: boolean) => { + if (append) { + await this.resultGrid.appendResult(result); + } else { + this.displayResults(result, assessmentType); + } + }); + } + finally { + btnInvokeAssessmentLoading.loading = false; + } + })); + + const btnGetAssessmentItems = view.modelBuilder.button() + .withProperties({ + label: this.getItemsLabel, + iconPath: targetIconPath, + }).component(); + const btnGetAssessmentItemsLoading = view.modelBuilder.loadingComponent() + .withItem(btnGetAssessmentItems) + .withProperties({ + loadingText: this.getItemsLabel, + showText: true, + loading: false + }).component(); + + this.toDispose.push(btnGetAssessmentItems.onDidClick(async () => { + btnGetAssessmentItemsLoading.loading = true; + try { + await this.engine.performAssessment(AssessmentType.AvailableRules, + async (result: azdata.SqlAssessmentResult, assessmentType: AssessmentType, append: boolean) => { + if (append) { + await this.resultGrid.appendResult(result); + } else { + this.displayResults(result, assessmentType); + } + }); + } + finally { + btnGetAssessmentItemsLoading.loading = false; + } + })); + + this.btnExportAsScript = view.modelBuilder.button() + .withProperties({ + label: localize('btnExportAsScript', "Export as script"), + iconPath: { + dark: this.extensionContext.asAbsolutePath('resources/dark/newquery_inverse.svg'), + light: this.extensionContext.asAbsolutePath('resources/light/newquery.svg') + }, + enabled: false + }).component(); + this.toDispose.push(this.btnExportAsScript.onDidClick(async () => { + this.engine.generateAssessmentScript(); + })); + + this.btnHTMLExport = view.modelBuilder.button() + .withProperties({ + label: localize('btnGeneratehtmlreport', "Create HTML Report"), + iconPath: { + dark: this.extensionContext.asAbsolutePath('resources/dark/newquery_inverse.svg'), + light: this.extensionContext.asAbsolutePath('resources/light/newquery.svg') + }, + enabled: false + }).component(); + + this.toDispose.push(this.btnHTMLExport.onDidClick(async () => { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.CreateHTMLReport); + const options: vscode.SaveDialogOptions = { + defaultUri: vscode.Uri.file(suggestReportFile(Date.now())), + filters: { 'HTML File': ['html'] } + }; + + const choosenPath = await vscode.window.showSaveDialog(options); + if (choosenPath !== undefined) { + const reportContent = await new HTMLReportBuilder(this.engine.recentResult.result, + this.engine.recentResult.dateUpdated, + this.engine.recentResult.connectionInfo).build(); + await fs.writeFile(choosenPath.fsPath, reportContent); + if (await vscode.window.showInformationMessage( + localize('asmtaction.openReport', "Report has been saved. Do you want to open it?"), + localize('asmtaction.label.open', "Open"), localize('asmtaction.label.cancel', "Cancel") + ) === localize('asmtaction.label.open', "Open")) { + vscode.env.openExternal(choosenPath); + } + } + })); + + + let btnViewSamples = view.modelBuilder.button() + .withProperties({ + label: localize('btnViewSamples', "View all rules and learn more on GitHub"), + iconPath: { + dark: this.extensionContext.asAbsolutePath('resources/dark/configuredashboard_inverse.svg'), + light: this.extensionContext.asAbsolutePath('resources/light/configuredashboard.svg') + }, + }).component(); + + this.toDispose.push(btnViewSamples.onDidClick(() => { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.LearnMoreAssessmentLink); + vscode.env.openExternal(vscode.Uri.parse('https://aka.ms/sql-assessment-api')); + })); + + return view.modelBuilder.toolbarContainer() + .withToolbarItems( + [ + { component: btnInvokeAssessmentLoading }, + { component: btnGetAssessmentItemsLoading }, + { component: this.btnExportAsScript }, + { component: this.btnHTMLExport }, + { component: btnViewSamples } + ] + ).component(); + } + + private displayResults(result: azdata.SqlAssessmentResult, assessmentType: AssessmentType): void { + this.apiVersionPropItem.value = result.apiVersion; + this.defaultRulesetPropItem.value = result.items?.length > 0 ? result.items[0].rulesetVersion : ''; + this.assessmentPropertiesContainer.propertyItems = [ + this.apiVersionPropItem, + this.defaultRulesetPropItem + ]; + + this.resultGrid.displayResult(result, assessmentType); + this.btnExportAsScript.enabled = this.btnHTMLExport.enabled = assessmentType === AssessmentType.InvokeAssessment; + } +} diff --git a/extensions/sql-assessment/src/tabs/historyTab.ts b/extensions/sql-assessment/src/tabs/historyTab.ts new file mode 100644 index 0000000000..daecbfc35e --- /dev/null +++ b/extensions/sql-assessment/src/tabs/historyTab.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { SqlAssessmentTab } from './sqlAssessmentTab'; +import { AssessmentEngine, AssessmentType } from '../engine'; +import { AssessmentResultGrid } from '../assessmentResultGrid'; +import { LocalizedStrings } from '../localized'; +import { TelemetryReporter, SqlAssessmentTelemetryView, SqlTelemetryActions } from '../telemetry'; + +const localize = nls.loadMessageBundle(); + +export class SqlAssessmentHistoryTab extends SqlAssessmentTab { + private engine: AssessmentEngine; + private toDispose: vscode.Disposable[] = []; + private summaryTable!: azdata.TableComponent; + private resultGrid!: AssessmentResultGrid; + + public constructor(extensionContext: vscode.ExtensionContext, engine: AssessmentEngine) { + super(extensionContext, LocalizedStrings.HISTORY_TAB_NAME, 'HistoryTab', { + dark: extensionContext.asAbsolutePath('resources/dark/history.svg'), + light: extensionContext.asAbsolutePath('resources/light/history.svg') + }); + + this.engine = engine; + } + + public dispose() { + this.toDispose.forEach(disposable => disposable.dispose()); + } + + async tabContent(view: azdata.ModelView): Promise { + TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.OpenHistory); + this.summaryTable = await this.createHistorySummaryTable(view); + + const root = view.modelBuilder.flexContainer() + .withItems([this.summaryTable]) + .withLayout({ + flexFlow: 'column', + width: '100%', + height: '100%' + }).component(); + + const title = view.modelBuilder.text().withProperties({ + value: '', + CSSStyles: { 'font-weight': 'bold', 'margin-block-start': '0px', 'margin-block-end': '0px', 'font-size': '20px', 'padding-left': '20px', 'padding-bottom': '20px' } + }).component(); + + const backLink = view.modelBuilder.hyperlink().withProperties({ + label: localize('asmt.history.back', "<< Back"), + url: '', + CSSStyles: { 'text-decoration': 'none', 'width': '150px' } + }).component(); + backLink.onDidClick(async () => { + this.resultGrid.dispose(); + + root.clearItems(); + root.addItem(this.summaryTable, { flex: '1 1 auto' }); + }); + + const infoPanel = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row' + }).withProperties({ + CSSStyles: { + 'padding-left': '15px' + } + }).component(); + infoPanel.addItem(backLink, { flex: '0 0 auto' }); + infoPanel.addItem(title); + + this.toDispose.push(this.summaryTable.onRowSelected(async () => { + if (this.summaryTable.selectedRows?.length === 1) { + let rowNumber: number = this.summaryTable.selectedRows[0]; + const historyResult = (await this.engine.readHistory())[rowNumber]; + + root.clearItems(); + + this.resultGrid = new AssessmentResultGrid(view); + this.toDispose.push(this.resultGrid); + await view.initializeModel(title); + + + this.resultGrid.displayResult(historyResult.result, AssessmentType.InvokeAssessment); + title.value = localize('asmt.history.resultsTitle', "Assessment Results from {0}", new Date(historyResult.dateUpdated).toLocaleString()); + root.addItem(infoPanel, { flex: `0 0 50px` }); + root.addItem(this.resultGrid.component); + this.summaryTable.selectedRows = []; + } + + })); + + return root; + } + + public async refresh() { + const historicalRecords = await this.engine.readHistory(); + this.summaryTable.data = historicalRecords.map(item => [ + new Date(item.dateUpdated).toLocaleString(), + item.result.items.filter(i => i.level === 'Error')?.length, + item.result.items.filter(i => i.level === 'Warning')?.length, + item.result.items.filter(i => i.level === 'Information')?.length + ]); + } + + private async createHistorySummaryTable(view: azdata.ModelView): Promise { + const cssHeader = 'no-borders align-with-header'; + return view.modelBuilder.table() + .withProperties({ + data: [], + columns: [ + { value: localize('asmt.history.summaryAsmtDate', "Assessment Date"), headerCssClass: cssHeader, width: 125 }, + { value: localize('asmt.history.summaryError', "Error"), headerCssClass: cssHeader, width: 100 }, + { value: localize('asmt.history.summaryWarning', "Warning"), headerCssClass: cssHeader, width: 100 }, + { value: localize('asmt.history.summaryInfo', "Information"), headerCssClass: cssHeader, width: 100 } + ], + height: '100%', + width: '100%' + }).component(); + } +} diff --git a/extensions/sql-assessment/src/tabs/sqlAssessmentTab.ts b/extensions/sql-assessment/src/tabs/sqlAssessmentTab.ts new file mode 100644 index 0000000000..13190a97c9 --- /dev/null +++ b/extensions/sql-assessment/src/tabs/sqlAssessmentTab.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; + +export abstract class SqlAssessmentTab implements azdata.Tab, vscode.Disposable { + title!: string; + content!: azdata.Component; + id!: string; + icon?: string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri; } | undefined; + + protected extensionContext: vscode.ExtensionContext; + + public constructor(extensionContext: vscode.ExtensionContext, title: string, id: string, icon: { light: string; dark: string }) { + this.title = title; + this.id = id; + this.icon = icon; + this.extensionContext = extensionContext; + } + public dispose() { + + } + + public async Create(view: azdata.ModelView): Promise { + this.content = await this.tabContent(view); + return this; + } + + abstract async tabContent(view: azdata.ModelView): Promise; +} + + diff --git a/extensions/sql-assessment/src/telemetry.ts b/extensions/sql-assessment/src/telemetry.ts new file mode 100644 index 0000000000..c00c87508b --- /dev/null +++ b/extensions/sql-assessment/src/telemetry.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import AdsTelemetryReporter from 'ads-extension-telemetry'; + +const packageJson = require('../package.json'); +export const TelemetryReporter = new AdsTelemetryReporter(packageJson.name, packageJson.version, packageJson.aiKey); + +export const SqlAssessmentTelemetryView = 'SqlAssessmentTab'; + +export enum SqlTelemetryActions { + InvokeServerAssessment = 'SqlAssessmentServerInvoke', + InvokeDatabaseAssessment = 'SqlAssessmentDatabaseInvoke', + GetServerAssessmentRules = 'SqlAssessmentServerGetRules', + GetDatabaseAssessmentRules = 'SqlAssessmentDatabaseGetRules', + ExportAssessmentResults = 'SqlAssessmentExportResult', + LearnMoreAssessmentLink = 'SqlAssessmentLearnMoreLink', + CreateHTMLReport = 'SqlAssessmentHTMLReport', + OpenHistory = 'SqlAssessmentOpenHistory', +} + diff --git a/extensions/sql-assessment/src/utils.ts b/extensions/sql-assessment/src/utils.ts new file mode 100644 index 0000000000..e47f05b42f --- /dev/null +++ b/extensions/sql-assessment/src/utils.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as os from 'os'; +import { promises as fs, existsSync, readdirSync } from 'fs'; + +export type TargetWithChildren = { target: string, children: string[] }; + +export function suggestFileName(prefix: string, ext: string, date: number): string { + const fileName = `${prefix}${generateDefaultFileName(new Date(date))}${ext}`; + return path.join(os.homedir(), fileName); +} + +export function suggestReportFile(date: number): string { + const fileName = `SqlAssessmentReport_${generateDefaultFileName(new Date(date))}.html`; + return path.join(os.homedir(), fileName); +} + +export async function createHistoryFileName(targetName: string, date: number): Promise { + const fileName = `${targetName}_${generateDefaultFileName(new Date(date))}.json`; + const dirPath = path.join(os.homedir(), 'SqlAssessmentHistory'); + + if (!existsSync(dirPath)) { + await fs.mkdir(dirPath); + } + + return path.join(dirPath, escapeFileName(fileName)); +} + +export async function readHistoryFileNames(targetName: string): Promise { + const dirPath = path.join(os.homedir(), 'SqlAssessmentHistory'); + + if (!existsSync(dirPath)) { + return []; + } + const files: string[] = readdirSync(dirPath); + + return files + .filter(file => file.startsWith(`${escapeFileName(targetName)}_`)) + .map(targetFile => { + let result: TargetWithChildren = { + target: path.join(dirPath, targetFile), + children: [] + }; + + const datePart = `_${targetFile.split('_')[1]}`; + result.children.push(...files.filter(f => f.endsWith(datePart))); + result.children = result.children.map(c => path.join(dirPath, c)); + + return result; + }); +} + +export function readHistoryFileName(fileName: string): string { + return path.join(os.homedir(), 'SqlAssessmentHistory', `${fileName}`); +} + +export function getAssessmentDate(fileName: string): number { + const file = path.parse(fileName).name; + return extractDate(file); +} + +function extractDate(fileName: string): number { + const strDate: string = fileName.split('_')[1].split('.')[0]; + const date = new Date( + Number(strDate.substr(0, 4)), // y + Number(strDate.substr(4, 2)) - 1, // m + Number(strDate.substr(6, 2)), // d + Number(strDate.substr(8, 2)), // h + Number(strDate.substr(10, 2)), // m + Number(strDate.substr(12, 2)) // s + ); + return date.getTime() - date.getTimezoneOffset() * 60 * 1000; +} + +function generateDefaultFileName(resultDate: Date): string { + return `${resultDate.toISOString().replace(/-/g, '').replace('T', '').replace(/:/g, '').split('.')[0]}`; +} + +export function htmlEscape(html: string): string { + return html.replace(/[<|>|&|"]/g, function (match) { + switch (match) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '"': return '"'; + case '\'': return '''; + default: return match; + } + }); +} + +function escapeFileName(str: string): string { + return str.replace(/\*/g, '_'); +} diff --git a/extensions/sql-assessment/yarn.lock b/extensions/sql-assessment/yarn.lock index 45f8b9278d..9bf78e3e12 100644 --- a/extensions/sql-assessment/yarn.lock +++ b/extensions/sql-assessment/yarn.lock @@ -2,7 +2,82 @@ # yarn lockfile v1 +"ads-extension-telemetry@github:Charles-Gagnon/ads-extension-telemetry#0.1.0": + version "0.1.0" + resolved "https://codeload.github.com/Charles-Gagnon/ads-extension-telemetry/tar.gz/70c2fea10e9ff6e329c4c5ec0b77017ada514b6d" + dependencies: + vscode-extension-telemetry "0.1.1" + +applicationinsights@1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-1.0.8.tgz#db6e3d983cf9f9405fe1ee5ba30ac6e1914537b5" + integrity sha512-KzOOGdphOS/lXWMFZe5440LUdFbrLpMvh2SaRxn7BmiI550KAoSb2gIhiq6kJZ9Ir3AxRRztjhzif+e5P5IXIg== + dependencies: + diagnostic-channel "0.2.0" + diagnostic-channel-publishers "0.2.1" + zone.js "0.7.6" + +diagnostic-channel-publishers@0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.2.1.tgz#8e2d607a8b6d79fe880b548bc58cc6beb288c4f3" + integrity sha1-ji1geottef6IC1SLxYzGvrKIxPM= + +diagnostic-channel@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/diagnostic-channel/-/diagnostic-channel-0.2.0.tgz#cc99af9612c23fb1fff13612c72f2cbfaa8d5a17" + integrity sha1-zJmvlhLCP7H/8TYSxy8sv6qNWhc= + dependencies: + semver "^5.3.0" + +semver@^5.3.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +vscode-extension-telemetry@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/vscode-extension-telemetry/-/vscode-extension-telemetry-0.1.1.tgz#91387e06b33400c57abd48979b0e790415ae110b" + integrity sha512-TkKKG/B/J94DP5qf6xWB4YaqlhWDg6zbbqVx7Bz//stLQNnfE9XS1xm3f6fl24c5+bnEK0/wHgMgZYKIKxPeUA== + dependencies: + applicationinsights "1.0.8" + +vscode-jsonrpc@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz#9bab9c330d89f43fc8c1e8702b5c36e058a01794" + integrity sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A== + +vscode-languageclient@^5.3.0-next.1: + version "5.3.0-next.9" + resolved "https://registry.yarnpkg.com/vscode-languageclient/-/vscode-languageclient-5.3.0-next.9.tgz#34f58017647f15cd86015f7af45935dc750611f7" + integrity sha512-BFA3X1y2EI2CfsSBy0KG2Xr5BOYfd/97jTmD+doqL6oj+cY8S7AmRCOwb2f9Hbjq8GWL7YC+OJ0leZEUSPgP0A== + dependencies: + semver "^6.3.0" + vscode-languageserver-protocol "^3.15.0-next.8" + +vscode-languageserver-protocol@^3.15.0-next.8: + version "3.15.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.15.3.tgz#3fa9a0702d742cf7883cb6182a6212fcd0a1d8bb" + integrity sha512-zrMuwHOAQRhjDSnflWdJG+O2ztMWss8GqUUB8dXLR/FPenwkiBNkMIJJYfSN6sgskvsF0rHAoBowNQfbyZnnvw== + dependencies: + vscode-jsonrpc "^5.0.1" + vscode-languageserver-types "3.15.1" + +vscode-languageserver-types@3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz#17be71d78d2f6236d414f0001ce1ef4d23e6b6de" + integrity sha512-+a9MPUQrNGRrGU630OGbYVQ+11iOIovjCkqxajPa9w57Sd5ruK8WQNsslzpa0x/QJqC8kRc2DUxWjIFwoNm4ZQ== + 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== + +zone.js@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.7.6.tgz#fbbc39d3e0261d0986f1ba06306eb3aeb0d22009" + integrity sha1-+7w50+AmHQmG8boGMG6zrrDSIAk= diff --git a/src/sql/workbench/browser/modelComponents/table.component.ts b/src/sql/workbench/browser/modelComponents/table.component.ts index 9c26578426..318fa8c3fe 100644 --- a/src/sql/workbench/browser/modelComponents/table.component.ts +++ b/src/sql/workbench/browser/modelComponents/table.component.ts @@ -274,8 +274,10 @@ export default class TableComponent extends ComponentBase this.registerPlugins(col, this._checkboxColumns[col])); Object.keys(this._buttonsColumns).forEach(col => this.registerPlugins(col, this._buttonsColumns[col])); + if (this.headerFilter === true) { this.registerFilterPlugin(); + this._tableData.clearFilter(); } if (this.ariaRowCount === -1) { this._table.removeAriaRowCount(); @@ -393,6 +395,7 @@ export default class TableComponent extends ComponentBase Object.values(dataObject)); this.layoutTable(); } else { this._tableData.clearFilter();