sql-assessment extension code (#12948)

sql-assessment extension on model view components base
This commit is contained in:
Vladimir Chernov
2020-10-19 22:43:22 +03:00
committed by GitHub
parent 72f7e8de52
commit 873668a7cf
24 changed files with 1538 additions and 5 deletions

View 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
];
}
}

View 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}`);
}
}
}

View 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>
`;
}
}

View 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'
};

View File

@@ -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();
}

View 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);
});
}
}

View 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;
}
}

View 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();
}
}

View 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>;
}

View 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',
}

View 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 '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '"': return '&quot;';
case '\'': return '&#39';
default: return match;
}
});
}
function escapeFileName(str: string): string {
return str.replace(/\*/g, '_');
}