mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-16 18:46:40 -05:00
sql-assessment extension code (#12948)
sql-assessment extension on model view components base
This commit is contained in:
263
extensions/sql-assessment/src/assessmentResultGrid.ts
Normal file
263
extensions/sql-assessment/src/assessmentResultGrid.ts
Normal file
@@ -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<azdata.TableComponentProperties>({
|
||||
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<void> {
|
||||
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<azdata.TextComponentProperties>({
|
||||
CSSStyles: { ...cssNoMarginFloatLeft, 'font-weight': 'bold', 'font-size': '16px', 'padding-bottom': '5px', 'display': 'block' }
|
||||
}).component();
|
||||
this.checkDescriptionPlaceholder = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
|
||||
CSSStyles: { ...cssNoMarginFloatLeft, 'padding-right': '2px' }
|
||||
}).component();
|
||||
this.clickHereLabel = view.modelBuilder.hyperlink().withProperties<azdata.HyperlinkComponentProperties>({
|
||||
label: localize('asmt.details.clickHere', "Click here"),
|
||||
url: '',
|
||||
CSSStyles: cssNoMarginFloatLeft
|
||||
}).component();
|
||||
const toLearnMoreText = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
|
||||
CSSStyles: { ...cssNoMarginFloatLeft, 'padding-left': '2px' },
|
||||
value: localize('asmt.details.toLearnMore', " to learn more.")
|
||||
}).component();
|
||||
const tagsCaption = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
|
||||
CSSStyles: cssBlockCaption,
|
||||
value: LocalizedStrings.TAGS_COLUMN_NAME
|
||||
}).component();
|
||||
this.tagsPlaceholder = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
|
||||
CSSStyles: cssNoMarginFloatLeft
|
||||
}).component();
|
||||
|
||||
this.asmtMessagePlaceholder = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
|
||||
CSSStyles: cssNoMarginFloatLeft
|
||||
}).component();
|
||||
|
||||
this.descriptionCaption = view.modelBuilder.text().withProperties<azdata.TextComponentProperties>({
|
||||
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<azdata.TextComponentProperties>({
|
||||
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
|
||||
];
|
||||
}
|
||||
}
|
||||
184
extensions/sql-assessment/src/engine.ts
Normal file
184
extensions/sql-assessment/src/engine.ts
Normal file
@@ -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<void> {
|
||||
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<azdata.ResultStatus> {
|
||||
TelemetryReporter.sendActionEvent(SqlAssessmentTelemetryView, SqlTelemetryActions.ExportAssessmentResults);
|
||||
return this.sqlAssessment.generateAssessmentScript(this.lastInvokedResults.result.items, '', '', azdata.TaskExecutionMode.script);
|
||||
}
|
||||
|
||||
public async readHistory(): Promise<SqlAssessmentRecord[]> {
|
||||
if (this.historicalRecords === undefined) {
|
||||
await this.loadHistory();
|
||||
}
|
||||
|
||||
return this.historicalRecords;
|
||||
}
|
||||
|
||||
private async loadHistory(): Promise<void> {
|
||||
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<void> {
|
||||
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<azdata.ConnectionProvider>(
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
extensions/sql-assessment/src/htmlReportGenerator.ts
Normal file
219
extensions/sql-assessment/src/htmlReportGenerator.ts
Normal file
@@ -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<string> {
|
||||
const serverInfo = await azdata.connection.getServerInfo(this._connectionInfo.connectionId);
|
||||
|
||||
let mainContent = `
|
||||
<html>
|
||||
<head>
|
||||
<title>${LocalizedStrings.REPORT_TITLE}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>${LocalizedStrings.REPORT_TITLE}</div>
|
||||
</div>
|
||||
<div style="font-style: italic;">${new Date(this._dateUpdated).toLocaleString(vscode.env.language)}</div>
|
||||
${this.buildVersionDetails(serverInfo)}
|
||||
<div style="margin-top: 20px;">
|
||||
${this.buildResultsSection()}
|
||||
</div>
|
||||
${this.buildStyleSection()}
|
||||
</body>
|
||||
</html>`;
|
||||
return mainContent;
|
||||
}
|
||||
|
||||
private instanceName(serverInfo: azdata.ServerInfo): string {
|
||||
const serverName = this._connectionInfo.serverName;
|
||||
if (['local', '(local)'].indexOf(serverName.toLowerCase()) >= 0) {
|
||||
|
||||
return serverInfo !== undefined
|
||||
? (<any>serverInfo)['machineName']
|
||||
: serverName;
|
||||
}
|
||||
return serverName;
|
||||
}
|
||||
|
||||
private buildVersionDetails(serverInfo: azdata.ServerInfo): string {
|
||||
return `
|
||||
<div class="details">
|
||||
<div>
|
||||
<span>${LocalizedStrings.API_VERSION}: ${this._assessmentResult.apiVersion}</span><br />
|
||||
<span>${LocalizedStrings.DEFAULT_RULESET_VERSION}: ${this._assessmentResult.items[0].rulesetVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>${LocalizedStrings.SECTION_TITLE_SQL_SERVER}: ${serverInfo.serverEdition} ${serverInfo.serverVersion}</span><br>
|
||||
<span>${LocalizedStrings.SERVER_INSTANCENAME}: ${this.instanceName(serverInfo)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<div class="target">${targetResults[0].targetType === azdata.sqlAssessment.SqlAssessmentTargetType.Server ? LocalizedStrings.RESULTS_FOR_INSTANCE : LocalizedStrings.RESULTS_FOR_DATABASE}: ${targetResults[0].targetName}</div>
|
||||
${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'))}
|
||||
</div>`;
|
||||
return content;
|
||||
}
|
||||
private buildSeveritySection(severityName: string, items: azdata.SqlAssessmentResultItem[]) {
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="severityBlock">
|
||||
<div>${LocalizedStrings.REPORT_SEVERITY_MESSAGE(severityName, items.length)}</div>
|
||||
<table>
|
||||
<tr><th>${LocalizedStrings.MESSAGE_COLUMN_NAME}</th><th>${LocalizedStrings.HELP_LINK_COLUMN_NAME}</th><th>${LocalizedStrings.TAGS_COLUMN_NAME}</th><th>${LocalizedStrings.CHECKID_COLUMN_NAME}</th></tr>
|
||||
${this.buildItemsRows(items)}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
private buildItemsRows(items: azdata.SqlAssessmentResultItem[]): string {
|
||||
let content = '';
|
||||
items.forEach(item => {
|
||||
content += `<tr>
|
||||
<td>${htmlEscape(item.message)}</td>
|
||||
<td><a href='${item.helpLink}' target='_blank;'>${LocalizedStrings.LEARN_MORE_LINK}</a></td>
|
||||
<td>${this.formatTags(item.tags)}</td>
|
||||
<td>${item.checkId}</td>
|
||||
</tr>`;
|
||||
});
|
||||
return content;
|
||||
}
|
||||
private formatTags(tags: string[]): string {
|
||||
return tags?.join(', ');
|
||||
}
|
||||
|
||||
private buildStyleSection(): string {
|
||||
return `
|
||||
<style>
|
||||
* {
|
||||
color: #4a4a4a;
|
||||
font-family: "Segoe WPC", "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
div {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header>* {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.target {
|
||||
font-size: 1.7em;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid silver;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
table th:nth-child(1) {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
table th:nth-child(2) {
|
||||
width: 80px;
|
||||
}
|
||||
table th:nth-child(3) {
|
||||
width: 200px;
|
||||
}
|
||||
table th:nth-child(4) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th {
|
||||
border-bottom: 1px solid silver;
|
||||
border-right: 1px dotted silver;
|
||||
padding: 3px 5px;
|
||||
white-space: normal;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: silver;
|
||||
}
|
||||
|
||||
div.severityBlock>div {
|
||||
font-size: larger;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
table th:nth-child(2),
|
||||
table td:nth-child(2) {
|
||||
display:none;
|
||||
}
|
||||
table th:nth-child(3) {
|
||||
width: 120px;
|
||||
}
|
||||
table th:nth-child(4) {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
}
|
||||
36
extensions/sql-assessment/src/localized.ts
Normal file
36
extensions/sql-assessment/src/localized.ts
Normal file
@@ -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'
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
62
extensions/sql-assessment/src/maincontroller.ts
Normal file
62
extensions/sql-assessment/src/maincontroller.ts
Normal file
@@ -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<boolean> {
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
305
extensions/sql-assessment/src/tabs/assessmentMainTab.ts
Normal file
305
extensions/sql-assessment/src/tabs/assessmentMainTab.ts
Normal file
@@ -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<azdata.Component> {
|
||||
|
||||
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<azdata.FlexContainer> {
|
||||
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<azdata.PropertiesContainerComponentProperties>({
|
||||
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<azdata.PropertiesContainerComponentProperties>({
|
||||
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<azdata.ToolbarContainer> {
|
||||
|
||||
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<azdata.ButtonProperties>({
|
||||
label: this.invokeAssessmentLabel,
|
||||
iconPath: targetIconPath,
|
||||
}).component();
|
||||
const btnInvokeAssessmentLoading = view.modelBuilder.loadingComponent()
|
||||
.withItem(btnInvokeAssessment)
|
||||
.withProperties<azdata.LoadingComponentProperties>({
|
||||
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<azdata.ButtonProperties>({
|
||||
label: this.getItemsLabel,
|
||||
iconPath: targetIconPath,
|
||||
}).component();
|
||||
const btnGetAssessmentItemsLoading = view.modelBuilder.loadingComponent()
|
||||
.withItem(btnGetAssessmentItems)
|
||||
.withProperties<azdata.LoadingComponentProperties>({
|
||||
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<azdata.ButtonProperties>({
|
||||
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<azdata.ButtonProperties>({
|
||||
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<azdata.ButtonProperties>({
|
||||
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;
|
||||
}
|
||||
}
|
||||
125
extensions/sql-assessment/src/tabs/historyTab.ts
Normal file
125
extensions/sql-assessment/src/tabs/historyTab.ts
Normal file
@@ -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<azdata.Component> {
|
||||
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<azdata.TextComponentProperties>({
|
||||
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<azdata.HyperlinkComponentProperties>({
|
||||
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<azdata.ComponentProperties>({
|
||||
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<azdata.TableComponent> {
|
||||
const cssHeader = 'no-borders align-with-header';
|
||||
return view.modelBuilder.table()
|
||||
.withProperties<azdata.TableComponentProperties>({
|
||||
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();
|
||||
}
|
||||
}
|
||||
35
extensions/sql-assessment/src/tabs/sqlAssessmentTab.ts
Normal file
35
extensions/sql-assessment/src/tabs/sqlAssessmentTab.ts
Normal file
@@ -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<SqlAssessmentTab> {
|
||||
this.content = await this.tabContent(view);
|
||||
return this;
|
||||
}
|
||||
|
||||
abstract async tabContent(view: azdata.ModelView): Promise<azdata.Component>;
|
||||
}
|
||||
|
||||
|
||||
23
extensions/sql-assessment/src/telemetry.ts
Normal file
23
extensions/sql-assessment/src/telemetry.ts
Normal file
@@ -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',
|
||||
}
|
||||
|
||||
98
extensions/sql-assessment/src/utils.ts
Normal file
98
extensions/sql-assessment/src/utils.ts
Normal file
@@ -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<string> {
|
||||
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<TargetWithChildren[]> {
|
||||
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, '_');
|
||||
}
|
||||
Reference in New Issue
Block a user