fluent ui + mermaid

This commit is contained in:
FrigaT
2025-12-25 17:32:55 +03:00
parent 0dae811dd0
commit 0711d06884
6 changed files with 2759 additions and 242 deletions

View File

@@ -1,51 +0,0 @@
using System.Text;
using Microsoft.SqlServer.TransactSql.ScriptDom;
using System;
using System.Linq;
using System.Collections.Generic;
namespace SQLLinter.Infrastructure.Diagram;
public static class FragmentDiagramBuilder
{
public static string RenderMermaid(TSqlFragment fragment)
{
if (fragment == null) return string.Empty;
var diagram = BpmnBuilder.Build(fragment);
return MermaidRenderer.RenderMarkdown(diagram);
}
public static string RenderHtmlSvg(TSqlFragment fragment)
{
if (fragment == null) return string.Empty;
var diagram = BpmnBuilder.Build(fragment);
// Use mermaid HTML instead of SVG fallback
return MermaidRenderer.RenderHtml(diagram);
}
// keep helpers used by earlier code if needed
private static string Escape(string s)
{
if (s == null) return string.Empty;
return System.Net.WebUtility.HtmlEncode(s).Replace("\n", " ").Replace("\r", " ");
}
private static string Truncate(string s, int len)
{
if (s == null) return string.Empty;
if (s.Length <= len) return s;
return s.Substring(0, len - 3) + "...";
}
private static string SanitizeId(string s)
{
if (string.IsNullOrEmpty(s)) return "id";
var sb = new StringBuilder();
foreach (var ch in s)
{
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
else sb.Append('_');
}
return sb.ToString();
}
}

View File

@@ -43,6 +43,7 @@ public static class MermaidRenderer
{
var procId = SanitizeId(proc.Id);
sb.AppendLine($" subgraph {procId} [\"{Escape(proc.Name)}\"]");
sb.AppendLine($" direction TB");
var startNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Start).ToList();
var taskNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Task || n.Type == BpmnNodeType.Gateway).ToList();
@@ -116,17 +117,6 @@ public static class MermaidRenderer
return sb.ToString();
}
public static string RenderHtml(BpmnDiagram diagram)
{
var content = ToMermaidContent(diagram);
var sb = new StringBuilder();
// Put raw mermaid text inside .mermaid container so mermaid.js can render it
sb.AppendLine("<div class=\"mermaid\">\n" + content + "\n</div>");
return sb.ToString();
}
private static string Escape(string s)
{
if (s == null) return string.Empty;

View File

@@ -1,5 +1,6 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using System.Reflection;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
@@ -7,31 +8,64 @@ namespace SQLLinter.Infrastructure.Reporters;
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);
public string Format(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
if (violations.Count == 0)
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
if (violations.Count == 0 && diagram == null)
{
return "<p><em>Нет нарушений</em></p>";
sb.AppendLine("<div class=\"no-violations\">");
sb.AppendLine("<div class=\"no-violations-content\">");
sb.AppendLine("<div class=\"no-violations-icon\">✅</div>");
sb.AppendLine("<h3 class=\"no-violations-title\">Проверка завершена</h3>");
sb.AppendLine("<p class=\"no-violations-description\">Нарушений правил SQL не обнаружено.</p>");
sb.AppendLine("</div>");
sb.AppendLine("</div>");
GenerateEndingHtml(sb, false);
return sb.ToString();
}
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key);
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
.OrderBy(g => g.Key)
.ToList();
int fileIndex = 0;
// --- Отчёты по файлам ---
foreach (var fileGroup in groupedByFile)
{
string divId = $"file_{fileIndex}";
sb.AppendLine($"<div id=\"{divId}\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Файл: {fileGroup.Key}</h1>");
string fileName = Path.GetFileName(fileGroup.Key);
var groupedBySeverity = fileGroup
sb.AppendLine($"""
<div id="file_{fileIndex}" class="file-report{(fileIndex == 0 ? " active" : "")}">
""");
// Заголовок файла - добавляем ПЕРЕД секциями с ошибками
sb.AppendLine($"""
<div class="file-title-container">
<div class="file-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 2V8H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="file-name">{fileName}</span>
</div>
</div>
""");
// Группируем по severity и выводим таблицы
var severityGroups = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key);
.OrderByDescending(g => g.Key)
.ToList();
foreach (var severityGroup in groupedBySeverity)
foreach (var severityGroup in severityGroups)
{
string severityClass = severityGroup.Key switch
{
@@ -41,202 +75,210 @@ public class HtmlReportFormatter : IReportFormatter
_ => ""
};
sb.AppendLine($"<div class=\"{severityClass}\">");
sb.AppendLine($"<h3>{severityGroup.Key}</h3>");
sb.AppendLine("<table>");
sb.AppendLine("<thead>");
sb.AppendLine("<tr><th>#</th><th>Строка</th><th>Колонка</th><th>Правило</th><th>Описание</th></tr>");
sb.AppendLine("</thead>");
sb.AppendLine("<tbody>");
int rowIndex = 1;
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
string severityTitle = severityGroup.Key switch
{
sb.AppendLine($"<tr><td class=\"index\">{rowIndex}</td><td class=\"line\">{v.Line}</td><td class=\"column\">{v.Column}</td><td class=\"rule\">{v.RuleName}</td><td>{v.Text}</td></tr>");
rowIndex++;
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
fileIndex++;
}
// Табы снизу
sb.AppendLine("<div class=\"tabs\">");
fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('file_{fileIndex}', this)\">{fileGroup.Key}</div>");
fileIndex++;
}
sb.AppendLine("</div>");
GenerateEndingHtml(sb);
return sb.ToString();
}
public string Format(List<IRuleViolation> violations, BpmnDiagram diagram)
{
if (violations.Count == 0)
{
return "<p><em>Нет нарушений</em></p>";
}
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key);
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
int fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
string divId = $"file_{fileIndex}";
sb.AppendLine($"<div id=\"{divId}\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Файл: {fileGroup.Key}</h1>");
var groupedBySeverity = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key);
foreach (var severityGroup in groupedBySeverity)
{
string severityClass = severityGroup.Key switch
{
RuleViolationSeverity.Critical => "critical",
RuleViolationSeverity.Warning => "warning",
RuleViolationSeverity.Info => "info",
RuleViolationSeverity.Critical => "Critical",
RuleViolationSeverity.Warning => "Warning",
RuleViolationSeverity.Info => "Info",
_ => ""
};
sb.AppendLine($"<div class=\"{severityClass}\">");
sb.AppendLine($"<h3>{severityGroup.Key}</h3>");
sb.AppendLine("<table>");
sb.AppendLine("<thead>");
sb.AppendLine("<tr><th>#</th><th>Строка</th><th>Колонка</th><th>Правило</th><th>Описание</th></tr>");
sb.AppendLine("</thead>");
sb.AppendLine("<tbody>");
sb.AppendLine($"""
<div class="severity-section {severityClass}">
<div class="severity-header">
<div class="severity-title">
<h3>{severityTitle}</h3>
<span class="severity-count">{severityGroup.Count()}</span>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th class="index">#</th>
<th class="line">Строка</th>
<th class="column">Колонка</th>
<th class="rule">Правило</th>
<th class="description">Описание</th>
</tr>
</thead>
<tbody>
""");
int rowIndex = 1;
int row = 1;
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"<tr><td class=\"index\">{rowIndex}</td><td class=\"line\">{v.Line}</td><td class=\"column\">{v.Column}</td><td class=\"rule\">{v.RuleName}</td><td>{v.Text}</td></tr>");
rowIndex++;
sb.AppendLine(
$"""
<tr>
<td class="index">{row}</td>
<td class="line">{v.Line}</td>
<td class="column">{v.Column}</td>
<td class="rule">{v.RuleName}</td>
<td class="description">{EscapeHtml(v.Text)}</td>
</tr>
""");
row++;
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
sb.AppendLine($"""
</tbody>
</table>
</div>
</div>
""");
}
sb.AppendLine("</div>");
fileIndex++;
}
//mermaid диаграмма
sb.AppendLine($"<div id=\"mermaid\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Диаграмма</h1>");
var mermaid = MermaidRenderer.RenderHtml(diagram);
sb.AppendLine(mermaid);
sb.AppendLine("</div>");
// Табы снизу
sb.AppendLine("<div class=\"tabs\">");
fileIndex = 0;
foreach (var fileGroup in groupedByFile)
// --- Вкладка с диаграммой ---
bool hasDiagram = diagram != null;
if (hasDiagram)
{
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('file_{fileIndex}', this)\">{fileGroup.Key}</div>");
fileIndex++;
sb.AppendLine($"""
<div id="mermaid" class="file-report">
<div class="diagram-toolbar">
<div class="toolbar-search">
<input type="text" id="diagramSearch" placeholder="Поиск по узлам (нажмите Enter)" onkeypress="handleSearchKeyPress(event)" />
<button class="search-clear" type="button"></button>
</div>
<div class="toolbar-actions">
<button class="toolbar-button" type="button" onclick="exportPng()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;">
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10L12 15L17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Экспорт PNG
</button>
<button class="toolbar-button secondary" id="resetViewBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;">
<path d="M3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z" stroke="currentColor" stroke-width="2"/>
<path d="M9 12L12 9L15 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 15V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Сбросить вид
</button>
</div>
</div>
<div id="diagramContainer">
<div id="diagramSvgContainer"></div>
<div id="minimap"></div>
</div>
<div id="mermaidSource" style="display:none">
{EscapeHtml(MermaidRenderer.ToMermaidContent(diagram!))}
</div>
</div>
""");
}
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('mermaid', this)\">Диграмма</div>");
sb.AppendLine("</div>");
// --- Табы ---
if (violations.Count > 0 || hasDiagram)
{
sb.AppendLine("""
<div class="tabs-container">
<div class="tabs">
""");
GenerateEndingHtml(sb);
// Табы для файлов
for (int i = 0; i < groupedByFile.Count; i++)
{
var fileGroup = groupedByFile[i];
string fileName = Path.GetFileName(fileGroup.Key);
string activeClass = i == 0 ? " active" : "";
string tabBadge = fileGroup.Count().ToString();
sb.AppendLine($"""
<div class="tab{activeClass}" data-target="file_{i}">
{fileName}
<span class="tab-badge">{tabBadge}</span>
</div>
""");
}
// Таб для диаграммы
if (hasDiagram)
{
sb.AppendLine("""
<div class="tab" data-target="mermaid">
Диаграмма
</div>
""");
}
sb.AppendLine("""
</div>
</div>
""");
}
GenerateEndingHtml(sb, hasDiagram);
return sb.ToString();
}
private void GenerateBeginningHtml(StringBuilder sb)
{
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ru\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<title>Отчёт по SQLпроверкам</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; color: #000; }");
sb.AppendLine("h1 { padding: 20px; margin: 0; background-color: #0078d4; color: white; }");
sb.AppendLine("h2 { margin-top: 20px; color: #333; }");
sb.AppendLine("h3 { margin-top: 15px; color: #555; }");
sb.AppendLine("table { border-collapse: collapse; width: 100%; margin: 20px 0; box-shadow: 0 2px 6px rgba(0,0,0,0.1); background-color: white; border-radius: 4px; overflow: hidden; }");
sb.AppendLine("th, td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; line-height: 1.2; }");
sb.AppendLine("th { background-color: #fafafa; font-weight: 600; }");
sb.AppendLine("td.line, td.column { width: 60px; text-align: center; }");
sb.AppendLine("td.rule { width: 300px; text-align: left; }");
sb.AppendLine("td.index { width: 40px; text-align: center; }");
sb.AppendLine(".critical { border-left: 4px solid #d13438; padding: 10px; margin-bottom: 20px; background-color: #fde7e9; }");
sb.AppendLine(".warning { border-left: 4px solid #ffaa44; padding: 10px; margin-bottom: 20px; background-color: #fff4ce; }");
sb.AppendLine(".info { border-left: 4px solid #0078d4; padding: 10px; margin-bottom: 20px; background-color: #deecf9; }");
sb.AppendLine(".tabs { position: fixed; bottom: 0; left: 0; right: 0; background-color: #ffffff; border-top: 1px solid #ccc; padding: 10px; display: flex; overflow-x: auto; scrollbar-width: thin; justify-content: flex-start; box-shadow: 0 -2px 6px rgba(0,0,0,0.1); }");
sb.AppendLine(".tab { margin-right: 10px; padding: 8px 16px; border-radius: 4px; background-color: #f3f2f1; cursor: pointer; transition: background-color 0.2s; }");
sb.AppendLine(".tab:hover { background-color: #e1dfdd; }");
sb.AppendLine(".tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(".file-report { display: none; padding: 20px 0; }");
sb.AppendLine(".file-report.active { display: block; }");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт по SQLпроверкам</title>
<style>
""");
// Тёмная тема
sb.AppendLine("@media (prefers-color-scheme: dark) {");
sb.AppendLine(" body { background-color: #1e1e1e; color: #ddd; }");
sb.AppendLine(" h1 { background-color: #005a9e; }");
sb.AppendLine(" h2, h3 { color: #ddd; }");
sb.AppendLine(" table { background-color: #2d2d2d; box-shadow: none; }");
sb.AppendLine(" th { background-color: #3c3c3c; color: #fff; }");
sb.AppendLine(" td { border: 1px solid #444; }");
sb.AppendLine(" .tabs { background-color: #2d2d2d; border-top: 1px solid #444; }");
sb.AppendLine(" .tab { background-color: #3c3c3c; color: #ddd; }");
sb.AppendLine(" .tab:hover { background-color: #555; }");
sb.AppendLine(" .tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(" .critical { background-color: #4d1f1f; border-left-color: #d13438; }");
sb.AppendLine(" .warning { background-color: #4d3b1f; border-left-color: #ffaa44; }");
sb.AppendLine(" .info { background-color: #1f3b4d; border-left-color: #0078d4; }");
sb.AppendLine("}");
sb.AppendLine("</style>");
sb.AppendLine("<script src=\"https://unpkg.com/mermaid@10/dist/mermaid.min.js\"></script>");
sb.AppendLine("<script>mermaid.initialize({ startOnLoad: true, securityLevel: 'loose' });</script>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
//sb.AppendLine("<h1>Отчёт по SQLпроверкам</h1>");
// Загружаем CSS из ресурсов и добавляем стили для заголовка файла
sb.AppendLine(LoadResource("HtmlFormatter.css"));
sb.AppendLine("""
</style>
</head>
<body>
<main id="main-content">
""");
}
private void GenerateEndingHtml(StringBuilder sb)
private void GenerateEndingHtml(StringBuilder sb, bool hasDiagram)
{
sb.AppendLine("""
</main>
""");
// JS для переключения
sb.AppendLine("<script>");
sb.AppendLine("function showReport(id, tab) {");
sb.AppendLine(" document.querySelectorAll('.file-report').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" document.getElementById(id).classList.add('active');");
sb.AppendLine(" document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" tab.classList.add('active');");
sb.AppendLine(" window.scrollTo({ top: 0, behavior: 'smooth' });");
sb.AppendLine("}");
sb.AppendLine("document.querySelector('.tabs').addEventListener('wheel', function(e) {");
sb.AppendLine(" e.preventDefault();");
sb.AppendLine(" this.scrollLeft += e.deltaY;");
sb.AppendLine("});");
sb.AppendLine("</script>");
sb.AppendLine("""
<script type="module">
""");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
// Загружаем JS из ресурсов
sb.AppendLine(LoadResource("HtmlFormatter.js"));
sb.AppendLine("""
</script>
</body>
</html>
""");
}
private static string LoadResource(string endsWith)
{
var assembly = Assembly.GetExecutingAssembly();
var name = assembly.GetManifestResourceNames()
.First(n => n.EndsWith(endsWith, StringComparison.OrdinalIgnoreCase));
using var stream = assembly.GetManifestResourceStream(name);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string EscapeHtml(string text)
{
return System.Net.WebUtility.HtmlEncode(text);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,15 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Remove="Infrastructure\Reporters\Static\HtmlFormatter.css" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.js" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="170.128.0" />
</ItemGroup>