using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
///
/// The management and compliance report as PDF: key figures, a plain-language summary, controls, instances and the
/// critical and high findings. Written for readers who are not DBAs.
///
public static class ManagementReport
{
public const int MaxFindingRows = 100;
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private static readonly TimeSpan UnreachableAfter = TimeSpan.FromMinutes(30);
private static readonly Color Text = new(0x10, 0x18, 0x28);
private static readonly Color Muted = new(0x66, 0x70, 0x85);
private static readonly Color Border = new(0xE4, 0xE7, 0xEC);
private static readonly Color Surface = new(0xF5, 0xF7, 0xFA);
private static readonly Color Brand = new(0x0E, 0x74, 0x90);
private static readonly Color Critical = new(0xD9, 0x2D, 0x20);
private static readonly Color High = new(0xE6, 0x6A, 0x0A);
private static readonly Color Medium = new(0xB5, 0x8B, 0x00);
private static readonly Color Good = new(0x06, 0x76, 0x47);
public static byte[] Render(ReportData data)
{
ArgumentNullException.ThrowIfNull(data);
InterFontResolver.Install();
var document = new Document();
document.Info.Title = $"Driftsrapport for SQL Server: {data.Organization}";
document.Info.Author = "SQLInsight Pro";
Styles(document);
var section = document.AddSection();
section.PageSetup = document.DefaultPageSetup.Clone();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.TopMargin = Unit.FromCentimeter(2.2);
section.PageSetup.BottomMargin = Unit.FromCentimeter(2);
section.PageSetup.LeftMargin = Unit.FromCentimeter(2);
section.PageSetup.RightMargin = Unit.FromCentimeter(2);
HeaderAndFooter(section, data);
Title(section, data);
KeyFigures(section, data);
Summary(section, data);
Controls(section, data);
Instances(section, data);
Findings(section, data);
ConfigurationChanges(section, data);
About(section, data);
var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument();
using var output = new MemoryStream();
renderer.PdfDocument.Save(output, false);
return output.ToArray();
}
private static void Styles(Document document)
{
var normal = document.Styles[StyleNames.Normal]!;
normal.Font.Name = InterFontResolver.Family;
normal.Font.Size = 9.5;
normal.Font.Color = Text;
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(4);
var heading = document.Styles[StyleNames.Heading1]!;
heading.Font.Size = 14;
heading.Font.Bold = true;
heading.ParagraphFormat.SpaceBefore = Unit.FromPoint(16);
heading.ParagraphFormat.SpaceAfter = Unit.FromPoint(6);
heading.ParagraphFormat.KeepWithNext = true;
var muted = document.Styles.AddStyle("Muted", StyleNames.Normal);
muted.Font.Color = Muted;
muted.Font.Size = 8.5;
}
private static void HeaderAndFooter(Section section, ReportData data)
{
var header = section.Headers.Primary.AddParagraph();
header.Style = "Muted";
header.AddText($"SQLInsight Pro · {data.Organization}");
header.Format.Borders.Bottom = new Border { Width = 0.5, Color = Border };
var footer = section.Footers.Primary.AddParagraph();
footer.Style = "Muted";
footer.AddText($"Driftsrapport {Date(data, data.From)} – {Date(data, data.To)} Side ");
footer.AddPageField();
footer.AddText(" af ");
footer.AddNumPagesField();
}
private static void Title(Section section, ReportData data)
{
var title = section.AddParagraph("Driftsrapport for SQL Server");
title.Format.Font.Size = 22;
title.Format.Font.Bold = true;
title.Format.SpaceAfter = Unit.FromPoint(2);
var subtitle = section.AddParagraph(data.Organization);
subtitle.Format.Font.Size = 13;
subtitle.Format.Font.Color = Brand;
var period = section.AddParagraph($"Periode {Date(data, data.From)} – {Date(data, data.To)} · dannet {DateTime(data, data.GeneratedAt)}");
period.Style = "Muted";
period.Format.SpaceAfter = Unit.FromPoint(14);
}
private static void KeyFigures(Section section, ReportData data)
{
var unreachable = data.Unreachable(UnreachableAfter).Count();
var figures = new (string Value, string Label, Color Color)[]
{
(data.Instances.Count.ToString(Danish), unreachable > 0 ? $"instanser, {unreachable} kan ikke nås" : "instanser, alle svarer", unreachable > 0 ? Critical : Text),
(data.Count(Severity.Critical).ToString(Danish), "kritiske fund", data.Count(Severity.Critical) > 0 ? Critical : Good),
(data.Count(Severity.High).ToString(Danish), "fund med høj alvor", data.Count(Severity.High) > 0 ? High : Good),
(data.Period.Resolved.ToString(Danish), $"løst i perioden ({data.Period.Opened} nye)", Good),
};
var table = section.AddTable();
table.Borders.Visible = false;
foreach (var _ in figures)
{
table.AddColumn(Unit.FromCentimeter(4.25));
}
var row = table.AddRow();
for (var i = 0; i < figures.Length; i++)
{
var cell = row.Cells[i];
cell.Shading.Color = Surface;
cell.Borders.Left = new Border { Width = 3, Color = Colors.White };
cell.Borders.Right = new Border { Width = 3, Color = Colors.White };
var value = cell.AddParagraph(figures[i].Value);
value.Format.Font.Size = 20;
value.Format.Font.Bold = true;
value.Format.Font.Color = figures[i].Color;
value.Format.SpaceBefore = Unit.FromPoint(6);
value.Format.LeftIndent = Unit.FromPoint(6);
var label = cell.AddParagraph(figures[i].Label);
label.Style = "Muted";
label.Format.LeftIndent = Unit.FromPoint(6);
label.Format.SpaceAfter = Unit.FromPoint(6);
}
}
private static void Summary(Section section, ReportData data)
{
section.AddParagraph("Sammenfatning", StyleNames.Heading1);
foreach (var sentence in SummarySentences(data))
{
var paragraph = section.AddParagraph("• " + sentence);
paragraph.Format.LeftIndent = Unit.FromPoint(10);
paragraph.Format.FirstLineIndent = Unit.FromPoint(-10);
}
}
/// The summary in plain language; public so it can be tested without reading the PDF.
public static IReadOnlyList SummarySentences(ReportData data)
{
ArgumentNullException.ThrowIfNull(data);
var sentences = new List();
var critical = data.Count(Severity.Critical);
var high = data.Count(Severity.High);
sentences.Add(critical > 0
? $"Der er {Count(critical, "kritisk fund", "kritiske fund")}, som bør håndteres straks, og {Count(high, "fund", "fund")} med høj alvor."
: high > 0 ? $"Der er ingen kritiske fund, men {Count(high, "fund", "fund")} med høj alvor, som bør planlægges løst." : "Der er hverken kritiske fund eller fund med høj alvor.");
var unreachable = data.Unreachable(UnreachableAfter).Select(i => i.Name).ToList();
if (unreachable.Count > 0)
{
sentences.Add($"{Count(unreachable.Count, "instans", "instanser")} kan ikke nås lige nu ({string.Join(", ", unreachable.Take(5))}{(unreachable.Count > 5 ? " …" : "")}); de kan ikke vurderes.");
}
void Control(string name, Func text)
{
var control = ComplianceControl.All.Single(c => c.Name == name);
var failing = data.Instances.Count(i => control.StatusOn(i.Name, data.RuleStates) == ControlStatus.NotMet);
if (failing > 0)
{
sentences.Add(text(failing, data.Instances.Count));
}
}
Control("Supporteret version", (n, total) => $"{n} af {total} instanser kører en SQL Server-version, som Microsoft ikke længere (fuldt) understøtter. Planlæg opgradering.");
Control("Opdateret", (n, total) => $"{n} af {total} instanser mangler de seneste opdateringer fra Microsoft.");
Control("Fuld backup", (n, _) => $"{Count(n, "instans", "instanser")} har databaser uden en nylig fuld backup. Det er en risiko for datatab.");
Control("Integritetstjek", (n, _) => $"{Count(n, "instans", "instanser")} mangler integritetstjek eller har registreret korrupte sider.");
var period = data.Period;
sentences.Add($"I perioden er der kommet {(period.Opened == 0 ? "ingen" : period.Opened.ToString(Danish))} nye fund til"
+ (period.Opened > 0 ? $" ({period.OpenedCriticalOrHigh} kritiske eller høje)" : "")
+ (period.Resolved == 0 ? ", og ingen er løst." : $", og {period.Resolved} er løst"
+ (period.AverageDaysToResolve is { } days ? $" efter gennemsnitligt {days.ToString("0.#", Danish)} dage." : ".")));
return sentences;
}
private static string Count(int n, string one, string many) => $"{n.ToString(Danish)} {(n == 1 ? one : many)}";
private static void Controls(Section section, ReportData data)
{
section.AddParagraph("Kontroller", StyleNames.Heading1);
var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt. Instanser, hvor kontrollen ikke er relevant for SQL Server-versionen, tælles ikke med.");
intro.Style = "Muted";
var table = Table(section, ("Kontrol", 5.2), ("Opfyldt", 1.8), ("Ikke opfyldt", 2.1), ("Ikke målt", 1.9), ("Instanser, der ikke opfylder", 6));
foreach (var control in ComplianceControl.All)
{
var statuses = data.Instances.Select(i => (i.Name, Status: control.StatusOn(i.Name, data.RuleStates))).ToList();
var failing = statuses.Where(s => s.Status == ControlStatus.NotMet).Select(s => s.Name).ToList();
var row = table.AddRow();
row.Cells[0].AddParagraph(control.Name).Format.Font.Bold = true;
row.Cells[0].AddParagraph(control.Description).Style = "Muted";
Number(row.Cells[1], statuses.Count(s => s.Status == ControlStatus.Met), Good);
Number(row.Cells[2], failing.Count, failing.Count > 0 ? Critical : Muted);
Number(row.Cells[3], statuses.Count(s => s.Status == ControlStatus.NotMeasured), Muted);
row.Cells[4].AddParagraph(failing.Count == 0 ? "–" : string.Join(", ", failing.Take(12)) + (failing.Count > 12 ? $" og {failing.Count - 12} flere" : ""));
}
}
private static void Instances(Section section, ReportData data)
{
section.AddParagraph("Instanser", StyleNames.Heading1);
var table = Table(section, ("Instans", 4.2), ("Version", 5.4), ("Kontakt", 2.8), ("Kritisk", 1.8), ("Høj", 1.4), ("Middel", 1.4));
foreach (var i in data.Instances)
{
var row = table.AddRow();
row.Cells[0].AddParagraph(i.Name).Format.Font.Bold = true;
row.Cells[1].AddParagraph(VersionText(i));
var reachable = !data.Unreachable(UnreachableAfter).Contains(i);
var contact = row.Cells[2].AddParagraph(reachable ? "Svarer" : "Kan ikke nås");
contact.Format.Font.Color = reachable ? Good : Critical;
Number(row.Cells[3], i.Critical, i.Critical > 0 ? Critical : Muted);
Number(row.Cells[4], i.High, i.High > 0 ? High : Muted);
Number(row.Cells[5], i.Medium, i.Medium > 0 ? Medium : Muted);
}
}
private static void Findings(Section section, ReportData data)
{
section.AddParagraph("Kritiske fund og fund med høj alvor", StyleNames.Heading1);
var findings = data.Active.Where(f => f.Severity >= Severity.High).OrderByDescending(f => f.Severity).ThenBy(f => f.FirstSeenAt).ToList();
if (findings.Count == 0)
{
section.AddParagraph("Ingen.");
return;
}
var table = Table(section, ("Alvor", 1.9), ("Instans", 3.2), ("Fund", 8.3), ("Set første gang", 2.6), ("Dage", 1));
foreach (var f in findings.Take(MaxFindingRows))
{
var row = table.AddRow();
var severity = row.Cells[0].AddParagraph(f.Severity == Severity.Critical ? "Kritisk" : "Høj");
severity.Format.Font.Bold = true;
severity.Format.Font.Color = f.Severity == Severity.Critical ? Critical : High;
row.Cells[1].AddParagraph(f.InstanceName);
row.Cells[2].AddParagraph(f.Title);
row.Cells[3].AddParagraph(Date(data, new DateTimeOffset(System.DateTime.SpecifyKind(f.FirstSeenAt, DateTimeKind.Utc))));
Number(row.Cells[4], (int)(data.GeneratedAt.UtcDateTime - f.FirstSeenAt).TotalDays, Text);
}
if (findings.Count > MaxFindingRows)
{
section.AddParagraph($"… og {findings.Count - MaxFindingRows} flere. Se alle i SQLInsight Pro.").Style = "Muted";
}
}
private static void ConfigurationChanges(Section section, ReportData data)
{
section.AddParagraph("Konfigurationsændringer i perioden", StyleNames.Heading1);
section.AddParagraph("Ændringer af serverindstillinger (sp_configure), trace flags, tjenestekonti, version og tempdb, som SQLInsight Pro har registreret. Tidspunktet er, hvornår ændringen blev opdaget (højst 15 minutter efter).").Style = "Muted";
if (data.ConfigurationChanges.Count == 0)
{
section.AddParagraph("Ingen.");
return;
}
var table = Table(section, ("Opdaget", 2.6), ("Instans", 3.2), ("Indstilling", 5.4), ("Fra", 2.6), ("Til", 2.6));
foreach (var c in data.ConfigurationChanges.Take(MaxFindingRows))
{
var row = table.AddRow();
row.Cells[0].AddParagraph(Date(data, new DateTimeOffset(System.DateTime.SpecifyKind(c.DetectedAt, DateTimeKind.Utc))));
row.Cells[1].AddParagraph(c.InstanceName);
row.Cells[2].AddParagraph($"{c.Area}: {c.Name}");
row.Cells[3].AddParagraph(c.OldValue ?? "–");
row.Cells[4].AddParagraph(c.NewValue ?? "–");
}
if (data.ConfigurationChanges.Count > MaxFindingRows)
{
section.AddParagraph($"… og {data.ConfigurationChanges.Count - MaxFindingRows} flere. Se dem under Konfiguration på instansens side.").Style = "Muted";
}
}
private static void About(Section section, ReportData data)
{
section.AddParagraph("Om rapporten", StyleNames.Heading1);
section.AddParagraph(
"Rapporten er dannet automatisk af SQLInsight Pro ud fra målinger på SQL Server-instanserne. Hvert fund har en forklaring, "
+ "målingerne bag og en anbefaling i SQLInsight Pro. Alvor: kritisk kan give nedetid eller datatab nu; høj bør løses inden for dage.").Style = "Muted";
section.AddParagraph($"SQLInsight Pro {data.Version}{(data.RulePack is { Length: > 0 } pack ? $", regelpakke {pack}" : "")}.").Style = "Muted";
}
private static Table Table(Section section, params (string Header, double Width)[] columns)
{
var table = section.AddTable();
table.Borders.Bottom = new Border { Width = 0.5, Color = Border };
table.Rows.LeftIndent = 0;
foreach (var (_, width) in columns)
{
var column = table.AddColumn(Unit.FromCentimeter(width));
column.LeftPadding = Unit.FromPoint(4);
column.RightPadding = Unit.FromPoint(4);
}
var header = table.AddRow();
header.HeadingFormat = true;
header.Shading.Color = Surface;
header.Format.Font.Bold = true;
header.Format.Font.Size = 8;
header.Format.Font.Color = Muted;
for (var i = 0; i < columns.Length; i++)
{
header.Cells[i].AddParagraph(columns[i].Header.ToUpperInvariant());
}
table.Format.SpaceBefore = Unit.FromPoint(3);
table.Format.SpaceAfter = Unit.FromPoint(3);
return table;
}
private static void Number(Cell cell, int value, Color color)
{
var paragraph = cell.AddParagraph(value.ToString(Danish));
paragraph.Format.Font.Color = color;
paragraph.Format.Font.Bold = value > 0 && color != Muted;
}
private static string VersionText(InstanceOverview i)
{
if (i.ProductVersion is not { } text || !SqlServerVersion.TryParse(text, out var version))
{
return "Ukendt";
}
var release = i.EngineEdition == 8 ? SqlServerRelease.AzureSqlManagedInstance : version.Release;
var level = $"{i.ProductLevel} {i.ProductUpdateLevel}".Trim();
return $"{release.DisplayName()}{(level.Length > 0 ? $" {level}" : "")} ({text})";
}
private static string Date(ReportData data, DateTimeOffset value) =>
TimeZoneInfo.ConvertTime(value, data.TimeZone).ToString("d. MMM yyyy", Danish);
private static string DateTime(ReportData data, DateTimeOffset value) =>
TimeZoneInfo.ConvertTime(value, data.TimeZone).ToString("d. MMM yyyy HH:mm", Danish);
}