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