- new collector instance-config: collation, edition, version, services and
accounts (sys.dm_server_services, 2008 R2 SP1+), Instant File
Initialization and Lock Pages in Memory (where the build has the
columns), tempdb files, default paths; plus a fingerprint of the whole
setup (sp_configure, trace flags, services, version, tempdb files)
- changes are computed against the previous collection, which the agent
restores from the repository after a restart; migration 0008 keeps them
in insight.ConfigurationChanges (as long as resolved findings)
- SQLI-CFG-013 "Konfiguration ændret": information for a day when
sp_configure, a trace flag or a service account changes (account names
are sensitive evidence)
- page /instanser/{navn}/konfiguration: instance, CPU and memory, services,
tempdb, trace flags, non-default sp_configure (default vs. in use vs.
configured) and the change log; linked from the instance page
- the management report lists configuration changes in the period
- trace flag reading shared by trace-flags and instance-config
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
365 lines
18 KiB
C#
365 lines
18 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>The summary in plain language; public so it can be tested without reading the PDF.</summary>
|
||
public static IReadOnlyList<string> SummarySentences(ReportData data)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(data);
|
||
var sentences = new List<string>();
|
||
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<int, int, string> 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);
|
||
}
|