sqlinsight-pro/src/SQLInsight.Reports/ManagementReport.cs
Søren Eggert Lundsteen Olsen ab8cfcad97 Configuration page with change log
- 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>
2026-10-02 08:12:26 +02:00

365 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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