diff --git a/Directory.Packages.props b/Directory.Packages.props index fc64c15..b9a197c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/README.md b/README.md index d4b80fa..74a146a 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,9 @@ Hvert **finding** indeholder: ### Fase 4: Videre - [ ] Anbefalinger på tværs af kunder: "denne CU har løst problemet hos andre" -- [ ] Rapporter (PDF) til ledelse og compliance +- [x] Rapporter (PDF) til ledelse og compliance: nøgletal, sammenfatning i almindeligt sprog, kontroller, instanser og alvorlige fund. Kan hentes i webbrugerfladen og sendes månedligt på mail. Se [docs/rapporter.md](docs/rapporter.md) - [ ] Mulighed for, at community selv bidrager med regler -- [ ] API til integration med eksisterende monitorering (Prometheus, Grafana, SCOM) - ---- +- [x] API til integration med eksisterende monitorering: JSON-API og Prometheus-endpoint med API-tokens, Grafana-dashboard, og Windows' hændelseslog med faste event-id'er til SCOM. Se [docs/integration.md](docs/integration.md) ## Understøttede versioner diff --git a/SQLInsight.slnx b/SQLInsight.slnx index c7edcfc..5bcd0b0 100644 --- a/SQLInsight.slnx +++ b/SQLInsight.slnx @@ -7,6 +7,7 @@ + diff --git a/docs/rapporter.md b/docs/rapporter.md new file mode 100644 index 0000000..bb99d6b --- /dev/null +++ b/docs/rapporter.md @@ -0,0 +1,37 @@ +# Rapporter + +SQLInsight Pro laver en **driftsrapport** som PDF til ledelse, it-chef og revision. Den er skrevet til læsere, der ikke er DBA'er: + +- **Nøgletal**: instanser (og hvor mange der ikke kan nås), kritiske fund, fund med høj alvor, og nye og løste fund i perioden. +- **Sammenfatning** i almindeligt sprog, fx "1 af 4 instanser kører en SQL Server-version, som Microsoft ikke længere (fuldt) understøtter". +- **Kontroller** til compliance: supporteret version, opdateret, fuld backup, log-backup, integritetstjek, sidekontrol, tilgængelighed, hærdning (xp_cmdshell, TRUSTWORTHY) og vedligeholdelsesjobs. For hver kontrol vises, hvor mange instanser der opfylder den, ikke opfylder den, eller ikke kunne måles, og hvilke instanser der ikke opfylder den. +- **Instanser** med version, CU-niveau og antal fund. +- **Kritiske fund og fund med høj alvor** med alder. + +"Ikke målt" betyder, at SQL Server-versionen, rettighederne eller en fejl forhindrede målingen. Det tælles aldrig som opfyldt. + +## Hent rapporten + +I webbrugerfladen under **Rapporter**: seneste 30 dage, kvartal eller år. Adressen `/rapporter/driftsrapport.pdf?dage=30` kan også hentes med et API-token (se [integration.md](integration.md)). + +Navnet på rapporten sættes i webbens `appsettings.json`: + +```json +"SQLInsight": { "Web": { "Organization": "Nordisk Logistik A/S", "TimeZone": "Europe/Copenhagen" } } +``` + +## Månedlig rapport på mail + +Agenten kan sende sidste måneds rapport automatisk. Den bruger SMTP-indstillingerne fra notifikationerne (`SQLInsight:Notifications:Email`: Host, Port, Security, From og eventuelt login). E-mail-notifikationerne behøver ikke være slået til. + +```json +"SQLInsight": { + "Reports": { + "Organization": "Nordisk Logistik A/S", + "TimeZone": "Europe/Copenhagen", + "Monthly": { "Enabled": true, "To": [ "it-chef@firma.dk", "revision@firma.dk" ], "Day": 1, "Hour": 7 } + } +} +``` + +Rapporten dækker den foregående kalendermåned og sendes på den angivne dag og time. Var agenten stoppet på det tidspunkt, sendes den, så snart den kører igen i samme måned. Den sendes kun én gang pr. måned. diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs index b4fe34e..dc55d69 100644 --- a/src/SQLInsight.Agent/Program.cs +++ b/src/SQLInsight.Agent/Program.cs @@ -71,6 +71,8 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(10)); builder.Services.AddHostedService(); +builder.Services.Configure(builder.Configuration.GetSection(SQLInsight.Agent.Reports.ReportOptions.SectionName)); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/src/SQLInsight.Agent/Reports/MonthlyReportService.cs b/src/SQLInsight.Agent/Reports/MonthlyReportService.cs new file mode 100644 index 0000000..0f0ce2c --- /dev/null +++ b/src/SQLInsight.Agent/Reports/MonthlyReportService.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Options; +using SQLInsight.Agent.Updates; +using SQLInsight.Notifications; +using SQLInsight.Reports; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Reports; + +public sealed class ReportOptions +{ + public const string SectionName = "SQLInsight:Reports"; + + /// Name on the report, e.g. the company or department. + public string Organization { get; set; } = "SQL Server-miljøet"; + + /// Time zone for dates and for when the month starts, e.g. Europe/Copenhagen. Default: the server's. + public string? TimeZone { get; set; } + + public MonthlyReportOptions Monthly { get; set; } = new(); +} + +/// The previous month's report by e-mail, using the SMTP settings under SQLInsight:Notifications:Email. +public sealed class MonthlyReportOptions +{ + public bool Enabled { get; set; } + public IList To { get; } = []; + + /// Day of the month to send on (1–28). + public int Day { get; set; } = 1; + + /// Local hour to send at. + public int Hour { get; set; } = 7; +} + +public sealed partial class MonthlyReportService( + IOptions options, + IOptions notifications, + IOptions updates, + InsightRepository repository, + TimeProvider time, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!options.Value.Monthly.Enabled) + { + return; + } + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30), time); + do + { + try + { + await RunOnceAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogFailed(logger, ex); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); + } + + /// Sends last month's report if it is due and not sent yet. Returns true if it was sent. + public async Task RunOnceAsync(CancellationToken cancellationToken) + { + var settings = options.Value; + var zone = settings.TimeZone is { Length: > 0 } id ? TimeZoneInfo.FindSystemTimeZoneById(id) : TimeZoneInfo.Local; + var marker = Path.Combine(updates.Value.Directories.Root, "reports", "monthly-last.txt"); + var last = File.Exists(marker) ? (await File.ReadAllTextAsync(marker, cancellationToken).ConfigureAwait(false)).Trim() : null; + if (DuePeriod(time.GetUtcNow(), zone, settings.Monthly.Day, settings.Monthly.Hour, last) is not { } period) + { + return false; + } + + var smtp = notifications.Value.Email; + if (string.IsNullOrWhiteSpace(smtp.Host) || settings.Monthly.To.Count == 0) + { + LogNotConfigured(logger); + return false; + } + + var data = await ReportData.LoadAsync(repository, settings.Organization, period.From, period.To, zone, time, cancellationToken).ConfigureAwait(false); + var message = ReportMailer.Build(smtp, settings.Monthly.To, data, ReportMailer.MonthName(TimeZoneInfo.ConvertTime(period.From, zone))); + await ReportMailer.SendAsync(smtp, message, cancellationToken).ConfigureAwait(false); + + Directory.CreateDirectory(Path.GetDirectoryName(marker)!); + await File.WriteAllTextAsync(marker, period.Key, cancellationToken).ConfigureAwait(false); + LogSent(logger, period.Key, settings.Monthly.To.Count); + return true; + } + + public sealed record Period(string Key, DateTimeOffset From, DateTimeOffset To); + + /// + /// The previous calendar month (in ) once the send day and hour of the current month has + /// passed, unless the current month's report ( = "yyyy-MM") has already gone out. + /// + public static Period? DuePeriod(DateTimeOffset now, TimeZoneInfo zone, int day, int hour, string? lastSent) + { + ArgumentNullException.ThrowIfNull(zone); + var local = TimeZoneInfo.ConvertTime(now, zone).DateTime; + var thisMonth = new DateTime(local.Year, local.Month, 1, 0, 0, 0, DateTimeKind.Unspecified); + var key = thisMonth.ToString("yyyy-MM", System.Globalization.CultureInfo.InvariantCulture); + var dueAt = thisMonth.AddDays(Math.Clamp(day, 1, 28) - 1).AddHours(Math.Clamp(hour, 0, 23)); + if (lastSent == key || local < dueAt) + { + return null; + } + + var from = thisMonth.AddMonths(-1); + return new Period(key, new DateTimeOffset(from, zone.GetUtcOffset(from)), new DateTimeOffset(thisMonth, zone.GetUtcOffset(thisMonth))); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Månedsrapporten ({Month}) er sendt til {Recipients} modtager(e).")] + private static partial void LogSent(ILogger logger, string month, int recipients); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Månedsrapporten er slået til, men der mangler SMTP-server (SQLInsight:Notifications:Email:Host) eller modtagere (SQLInsight:Reports:Monthly:To).")] + private static partial void LogNotConfigured(ILogger logger); + + [LoggerMessage(Level = LogLevel.Error, Message = "Månedsrapporten kunne ikke sendes.")] + private static partial void LogFailed(ILogger logger, Exception exception); +} diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj index 7fb2300..34fe7f7 100644 --- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj +++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj @@ -13,6 +13,7 @@ + diff --git a/src/SQLInsight.Notifications/EmailChannel.cs b/src/SQLInsight.Notifications/EmailChannel.cs index e1b2855..d586e69 100644 --- a/src/SQLInsight.Notifications/EmailChannel.cs +++ b/src/SQLInsight.Notifications/EmailChannel.cs @@ -18,8 +18,13 @@ public sealed class EmailChannel(EmailOptions options) : INotificationChannel public async Task SendAsync(IReadOnlyList notifications, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(notifications); - var message = Build(options, notifications); + await SendMessageAsync(options, Build(options, notifications), cancellationToken).ConfigureAwait(false); + } + /// Sends a message through the configured SMTP server (also used for scheduled reports). + public static async Task SendMessageAsync(EmailOptions options, MimeMessage message, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); using var client = new SmtpClient(); var security = options.Security.ToUpperInvariant() switch { diff --git a/src/SQLInsight.Reports/Fonts/Inter-Bold.ttf b/src/SQLInsight.Reports/Fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/src/SQLInsight.Reports/Fonts/Inter-Bold.ttf differ diff --git a/src/SQLInsight.Reports/Fonts/Inter-Italic.ttf b/src/SQLInsight.Reports/Fonts/Inter-Italic.ttf new file mode 100644 index 0000000..e7ed211 Binary files /dev/null and b/src/SQLInsight.Reports/Fonts/Inter-Italic.ttf differ diff --git a/src/SQLInsight.Reports/Fonts/Inter-OFL.txt b/src/SQLInsight.Reports/Fonts/Inter-OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/src/SQLInsight.Reports/Fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/SQLInsight.Reports/Fonts/Inter-Regular.ttf b/src/SQLInsight.Reports/Fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/src/SQLInsight.Reports/Fonts/Inter-Regular.ttf differ diff --git a/src/SQLInsight.Reports/InterFontResolver.cs b/src/SQLInsight.Reports/InterFontResolver.cs new file mode 100644 index 0000000..2a306cc --- /dev/null +++ b/src/SQLInsight.Reports/InterFontResolver.cs @@ -0,0 +1,40 @@ +using PdfSharp.Fonts; + +namespace SQLInsight.Reports; + +/// Serves the embedded Inter font to PDFsharp, so no system fonts are needed (Windows or Linux). +internal sealed class InterFontResolver : IFontResolver +{ + public const string Family = "Inter"; + + private static readonly Lock Gate = new(); + private static bool _installed; + + public static void Install() + { + lock (Gate) + { + if (!_installed) + { + GlobalFontSettings.FontResolver = new InterFontResolver(); + _installed = true; + } + } + } + + public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic) => + new(bold ? "Inter-Bold" : italic ? "Inter-Italic" : "Inter-Regular"); + + public byte[]? GetFont(string faceName) + { + using var stream = typeof(InterFontResolver).Assembly.GetManifestResourceStream($"SQLInsight.Reports.Fonts.{faceName}.ttf"); + if (stream is null) + { + return null; + } + + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/src/SQLInsight.Reports/ManagementReport.cs b/src/SQLInsight.Reports/ManagementReport.cs new file mode 100644 index 0000000..b67b1d3 --- /dev/null +++ b/src/SQLInsight.Reports/ManagementReport.cs @@ -0,0 +1,337 @@ +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); + 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 versionen, rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt."); + 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 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); +} diff --git a/src/SQLInsight.Reports/ReportData.cs b/src/SQLInsight.Reports/ReportData.cs new file mode 100644 index 0000000..0f246bc --- /dev/null +++ b/src/SQLInsight.Reports/ReportData.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using SQLInsight.Core.Findings; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Reports; + +/// Everything a management report shows, read from the repository in one go. +public sealed record ReportData +{ + public required string Organization { get; init; } + public required DateTimeOffset From { get; init; } + public required DateTimeOffset To { get; init; } + public required DateTimeOffset GeneratedAt { get; init; } + public required TimeZoneInfo TimeZone { get; init; } + public required IReadOnlyList Instances { get; init; } + public required IReadOnlyList Active { get; init; } + public required IReadOnlyList RuleStates { get; init; } + public required PeriodStats Period { get; init; } + public string? RulePack { get; init; } + + public string Version { get; init; } = + typeof(ReportData).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0.0.0"; + + public int Count(Severity severity) => Active.Count(f => f.Severity == severity); + + public IEnumerable Unreachable(TimeSpan window) => + Instances.Where(i => i.IsFailing || i.LastSeenAt is null || GeneratedAt.UtcDateTime - i.LastSeenAt > window); + + public static async Task LoadAsync(InsightRepository repository, string organization, DateTimeOffset from, DateTimeOffset to, TimeZoneInfo timeZone, TimeProvider time, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(time); + return new ReportData + { + Organization = organization, + From = from, + To = to, + GeneratedAt = time.GetUtcNow(), + TimeZone = timeZone, + Instances = await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false), + Active = await repository.GetActiveFindingsAsync(cancellationToken: cancellationToken).ConfigureAwait(false), + RuleStates = await repository.GetAllRuleStatesAsync(cancellationToken).ConfigureAwait(false), + Period = await repository.GetPeriodStatsAsync(from, to, cancellationToken).ConfigureAwait(false), + RulePack = (await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false))?.RulePackVersion, + }; + } +} + +/// +/// A control an auditor asks about, backed by one or more rules. Not met when any of its rules has findings on the +/// instance; met when the first rule was evaluated and found nothing; otherwise not measured. +/// +public sealed record ComplianceControl(string Name, string Description, params string[] Rules) +{ + public static readonly IReadOnlyList All = + [ + new("Supporteret version", "SQL Server-versionen har support (sikkerhedsopdateringer) fra Microsoft.", "SQLI-VER-001"), + new("Opdateret", "Seneste cumulative update eller sikkerhedsopdatering er installeret.", "SQLI-VER-002"), + new("Fuld backup", "Alle databaser har en nylig fuld backup.", "SQLI-DB-007"), + new("Log-backup", "Databaser i FULL recovery får taget log-backup.", "SQLI-DB-008"), + new("Integritetstjek", "DBCC CHECKDB er kørt for nylig, og der er ingen registrerede korrupte sider.", "SQLI-DB-009", "SQLI-DB-010"), + new("Sidekontrol", "Databaserne bruger PAGE_VERIFY CHECKSUM.", "SQLI-DB-004"), + new("Tilgængelighed", "Alle databaser er online, og availability groups er sunde.", "SQLI-DB-001", "SQLI-AG-001"), + new("Hærdning", "xp_cmdshell er slået fra, og ingen databaser er TRUSTWORTHY.", "SQLI-CFG-008", "SQLI-DB-005"), + new("Vedligeholdelsesjobs", "SQL Agent kører, og jobs fejler ikke.", "SQLI-JOB-001", "SQLI-JOB-002"), + ]; + + public ControlStatus StatusOn(string instance, IEnumerable states) + { + var relevant = states.Where(s => s.Instance == instance && Rules.Contains(s.RuleId)).ToList(); + if (relevant.Count == 0) + { + return ControlStatus.NotMeasured; + } + + if (relevant.Any(s => s.Outcome == RuleOutcome.Findings)) + { + return ControlStatus.NotMet; + } + + // The first rule is the core of the control; the others only count where they apply (an AG rule on an + // instance without availability groups, for example). + return relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.Passed) ? ControlStatus.Met : ControlStatus.NotMeasured; + } +} + +public enum ControlStatus +{ + Met, + NotMet, + NotMeasured, +} diff --git a/src/SQLInsight.Reports/ReportMailer.cs b/src/SQLInsight.Reports/ReportMailer.cs new file mode 100644 index 0000000..fc4a93b --- /dev/null +++ b/src/SQLInsight.Reports/ReportMailer.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using MimeKit; +using SQLInsight.Notifications; + +namespace SQLInsight.Reports; + +/// Sends the management report as a PDF attachment through the same SMTP server as notifications. +public static class ReportMailer +{ + private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK"); + + public static MimeMessage Build(EmailOptions smtp, IEnumerable recipients, ReportData data, string periodName) + { + ArgumentNullException.ThrowIfNull(smtp); + ArgumentNullException.ThrowIfNull(recipients); + ArgumentNullException.ThrowIfNull(data); + var message = new MimeMessage(); + message.From.Add(MailboxAddress.Parse(smtp.From)); + foreach (var to in recipients) + { + message.To.Add(MailboxAddress.Parse(to)); + } + + message.Subject = $"SQLInsight: Driftsrapport for {data.Organization}, {periodName}"; + var body = new BodyBuilder + { + TextBody = $"Driftsrapport for SQL Server, {periodName}.\n\n" + + string.Join("\n", ManagementReport.SummarySentences(data).Select(s => "• " + s)) + + "\n\nDen fulde rapport er vedhæftet som PDF.\n\nSendt af SQLInsight Pro", + }; + body.Attachments.Add($"driftsrapport-{data.From.ToString("yyyy-MM", CultureInfo.InvariantCulture)}.pdf", ManagementReport.Render(data), new ContentType("application", "pdf")); + message.Body = body.ToMessageBody(); + return message; + } + + public static string MonthName(DateTimeOffset firstOfMonth) => firstOfMonth.ToString("MMMM yyyy", Danish); + + public static Task SendAsync(EmailOptions smtp, MimeMessage message, CancellationToken cancellationToken) => + EmailChannel.SendMessageAsync(smtp, message, cancellationToken); +} diff --git a/src/SQLInsight.Reports/SQLInsight.Reports.csproj b/src/SQLInsight.Reports/SQLInsight.Reports.csproj new file mode 100644 index 0000000..52e7b64 --- /dev/null +++ b/src/SQLInsight.Reports/SQLInsight.Reports.csproj @@ -0,0 +1,19 @@ + + + SQLInsight.Reports + + + + + + + + + + + + + + + + diff --git a/src/SQLInsight.Repository/InsightRepository.Reports.cs b/src/SQLInsight.Repository/InsightRepository.Reports.cs new file mode 100644 index 0000000..8b3e8aa --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Reports.cs @@ -0,0 +1,37 @@ +using Dapper; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Repository; + +public sealed record InstanceRuleState(string Instance, string RuleId, RuleOutcome Outcome, string? Reason); + +public sealed record PeriodStats(int Opened, int OpenedCriticalOrHigh, int Resolved, double? AverageDaysToResolve); + +public sealed partial class InsightRepository +{ + /// The latest outcome of every rule on every instance. + public async Task> GetAllRuleStatesAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string Instance, string RuleId, byte Outcome, string? Reason)>(""" + SELECT i.Name, r.RuleId, r.Outcome, r.Reason + FROM insight.RuleStates r JOIN insight.Instances i ON i.InstanceId = r.InstanceId + ORDER BY i.Name, r.RuleId; + """).ConfigureAwait(false); + return rows.Select(r => new InstanceRuleState(r.Instance, r.RuleId, (RuleOutcome)r.Outcome, r.Reason)).ToList(); + } + + /// Findings opened and resolved between and . + public async Task GetPeriodStatsAsync(DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleAsync(""" + SELECT + (SELECT COUNT(*) FROM insight.Findings WHERE FirstSeenAt >= @from AND FirstSeenAt < @to) AS Opened, + (SELECT COUNT(*) FROM insight.Findings WHERE FirstSeenAt >= @from AND FirstSeenAt < @to AND Severity >= 3) AS OpenedCriticalOrHigh, + (SELECT COUNT(*) FROM insight.Findings WHERE ResolvedAt >= @from AND ResolvedAt < @to) AS Resolved, + (SELECT AVG(CAST(DATEDIFF(minute, FirstSeenAt, ResolvedAt) AS float)) / 1440.0 + FROM insight.Findings WHERE ResolvedAt >= @from AND ResolvedAt < @to) AS AverageDaysToResolve; + """, new { from = Utc(from), to = Utc(to) }).ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Web/Components/Layout/MainLayout.razor b/src/SQLInsight.Web/Components/Layout/MainLayout.razor index 978444f..1eced5d 100644 --- a/src/SQLInsight.Web/Components/Layout/MainLayout.razor +++ b/src/SQLInsight.Web/Components/Layout/MainLayout.razor @@ -11,6 +11,7 @@ Overvågning Instanser Alle fund + Rapporter System Community diff --git a/src/SQLInsight.Web/Components/Pages/Reports.razor b/src/SQLInsight.Web/Components/Pages/Reports.razor new file mode 100644 index 0000000..5d680f0 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/Reports.razor @@ -0,0 +1,18 @@ +@page "/rapporter" +@inject Microsoft.Extensions.Options.IOptions Options + +Rapporter · SQLInsight Pro + +

Rapporter

+

Driftsrapport til ledelse og revision som PDF: nøgletal, en sammenfatning i almindeligt sprog, kontroller (backup, integritetstjek, supporteret version, hærdning …), instanser og de alvorligste fund.

+ +
+

Driftsrapport for @Options.Value.Organization

+

Vælg periode. Nye og løste fund tælles i perioden; aktive fund og kontroller viser situationen nu.

+ +

Rapporten kan også sendes automatisk hver måned. Se SQLInsight:Reports i agentens konfiguration.

+
diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index f4a1480..7a50ea9 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -98,6 +98,17 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit : Results.NotFound()); IntegrationEndpoints.Map(app); + +// Management and compliance report as PDF, for the last N days. +app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions options, CancellationToken ct) => +{ + var days = Math.Clamp(dage ?? 30, 1, 366); + var settings = options.Value; + var zone = settings.TimeZone is { Length: > 0 } id ? TimeZoneInfo.FindSystemTimeZoneById(id) : TimeZoneInfo.Local; + var now = DateTimeOffset.UtcNow; + var data = await SQLInsight.Reports.ReportData.LoadAsync(repository, settings.Organization, now.AddDays(-days), now, zone, TimeProvider.System, ct); + return Results.File(SQLInsight.Reports.ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf"); +}); app.MapRazorComponents(); // The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader. diff --git a/src/SQLInsight.Web/SQLInsight.Web.csproj b/src/SQLInsight.Web/SQLInsight.Web.csproj index 213b2b1..8d07a7c 100644 --- a/src/SQLInsight.Web/SQLInsight.Web.csproj +++ b/src/SQLInsight.Web/SQLInsight.Web.csproj @@ -5,6 +5,7 @@ + diff --git a/src/SQLInsight.Web/WebOptions.cs b/src/SQLInsight.Web/WebOptions.cs index ee931e8..b8d2dd2 100644 --- a/src/SQLInsight.Web/WebOptions.cs +++ b/src/SQLInsight.Web/WebOptions.cs @@ -20,4 +20,7 @@ public sealed class WebOptions /// Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default. public bool ApiIncludesSensitiveEvidence { get; set; } + + /// Name shown on reports, e.g. the company or department. + public string Organization { get; set; } = "SQL Server-miljøet"; } diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index a716fb8..c16a95b 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -157,3 +157,6 @@ meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; } details { border-top: 1px solid var(--border); padding: .6rem 0; } details summary { cursor: pointer; font-weight: 500; } details .script pre { max-height: 32rem; } +.report-links { display: flex; gap: .6rem; flex-wrap: wrap; } +a.button { display: inline-block; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; } +a.button:hover { filter: brightness(1.08); } diff --git a/tests/SQLInsight.Agent.Tests/MonthlyReportTests.cs b/tests/SQLInsight.Agent.Tests/MonthlyReportTests.cs new file mode 100644 index 0000000..6eeefbd --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/MonthlyReportTests.cs @@ -0,0 +1,35 @@ +using SQLInsight.Agent.Reports; + +namespace SQLInsight.Agent.Tests; + +public class MonthlyReportTests +{ + private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen"); + + [Fact] + public void Previous_month_is_due_after_the_send_hour_across_a_daylight_saving_change() + { + var period = MonthlyReportService.DuePeriod(new DateTimeOffset(2026, 11, 1, 7, 30, 0, TimeSpan.FromHours(1)), Copenhagen, day: 1, hour: 7, lastSent: "2026-10"); + + Assert.NotNull(period); + Assert.Equal("2026-11", period.Key); + Assert.Equal(new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.FromHours(2)), period.From); + Assert.Equal(new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.FromHours(1)), period.To); + } + + [Fact] + public void Nothing_is_due_before_the_send_time_or_after_it_was_sent() + { + Assert.Null(MonthlyReportService.DuePeriod(new DateTimeOffset(2026, 11, 1, 6, 59, 0, TimeSpan.FromHours(1)), Copenhagen, 1, 7, "2026-10")); + Assert.Null(MonthlyReportService.DuePeriod(new DateTimeOffset(2026, 11, 3, 6, 0, 0, TimeSpan.FromHours(1)), Copenhagen, 5, 7, "2026-10")); + Assert.Null(MonthlyReportService.DuePeriod(new DateTimeOffset(2026, 11, 20, 12, 0, 0, TimeSpan.FromHours(1)), Copenhagen, 1, 7, "2026-11")); + } + + [Fact] + public void A_report_missed_while_the_agent_was_down_is_sent_later_in_the_month() + { + var period = MonthlyReportService.DuePeriod(new DateTimeOffset(2027, 1, 14, 9, 0, 0, TimeSpan.FromHours(1)), Copenhagen, 1, 7, lastSent: null); + + Assert.Equal(new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.FromHours(1)), period!.From); + } +} diff --git a/tests/SQLInsight.IntegrationTests/ReportTests.cs b/tests/SQLInsight.IntegrationTests/ReportTests.cs new file mode 100644 index 0000000..7822b81 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/ReportTests.cs @@ -0,0 +1,155 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DotNet.Testcontainers.Builders; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SQLInsight.Agent.Reports; +using SQLInsight.Agent.Updates; +using SQLInsight.Core.Findings; +using SQLInsight.Notifications; +using SQLInsight.Core.Instances; +using SQLInsight.Reports; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.IntegrationTests; + +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class ReportTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightRpt_" + Guid.NewGuid().ToString("N")[..8]; + private InsightRepository _repository = null!; + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + + async Task Instance(string name, string version, string cu, params (string Rule, RuleOutcome Outcome, Severity Severity, string Title)[] rules) + { + var id = await _repository.UpsertInstanceAsync(name, new InstanceInfo + { + ServerName = name.ToUpperInvariant(), Version = SqlServerVersion.Parse(version), ProductLevel = "RTM", ProductUpdateLevel = cu, + Edition = "Enterprise Edition", EngineEdition = EngineEdition.Enterprise, + }, "SqlClient", false, DateTimeOffset.UtcNow); + await _repository.SaveRuleResultsAsync(id, rules.Select(r => new RuleResult + { + RuleId = r.Rule, RuleTitle = r.Title, Outcome = r.Outcome, + Findings = r.Outcome == RuleOutcome.Findings + ? [new Finding { RuleId = r.Rule, Severity = r.Severity, Title = r.Title, Explanation = "Forklaring.", Recommendation = "Anbefaling." }] + : [], + }).ToList(), DateTimeOffset.UtcNow); + } + + await Instance("prod-sql01", "15.0.4385.2", "CU20", + ("SQLI-VER-001", RuleOutcome.Passed, Severity.Info, "Supportstatus"), + ("SQLI-VER-002", RuleOutcome.Findings, Severity.High, "SQL Server 2019 kører 15.0.4385.2; seneste er CU32"), + ("SQLI-DB-007", RuleOutcome.Findings, Severity.Critical, "Ingen fuld backup af Økonomi i 9 dage"), + ("SQLI-DB-009", RuleOutcome.Passed, Severity.Info, "CHECKDB"), + ("SQLI-CFG-008", RuleOutcome.Passed, Severity.Info, "xp_cmdshell")); + await Instance("legacy-sql05", "13.0.6300.2", "SP3", + ("SQLI-VER-001", RuleOutcome.Findings, Severity.High, "SQL Server 2016 er ude af udvidet support om 3 uger"), + ("SQLI-DB-007", RuleOutcome.Passed, Severity.Info, "Backup"), + ("SQLI-DB-009", RuleOutcome.NotMeasurable, Severity.Info, "CHECKDB")); + } + + public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + + private Task LoadAsync() => ReportData.LoadAsync(_repository, "Nordisk Logistik A/S", DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddMinutes(1), + TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen"), TimeProvider.System, CancellationToken.None); + + [Fact] + public async Task Report_is_a_pdf_with_controls_and_a_plain_language_summary() + { + var data = await LoadAsync(); + + var pdf = ManagementReport.Render(data); + if (Environment.GetEnvironmentVariable("SQLINSIGHT_REPORT_OUT") is { Length: > 0 } path) + { + await File.WriteAllBytesAsync(path, pdf); + } + + Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(pdf, 0, 5)); + var summary = ManagementReport.SummarySentences(data); + Assert.Contains(summary, s => s.StartsWith("Der er 1 kritisk fund, som bør håndteres straks, og 2 fund med høj alvor.", StringComparison.Ordinal)); + Assert.Contains(summary, s => s.StartsWith("1 af 2 instanser kører en SQL Server-version", StringComparison.Ordinal)); + Assert.Contains(summary, s => s.StartsWith("1 instans har databaser uden en nylig fuld backup", StringComparison.Ordinal)); + Assert.Contains(summary, s => s == "I perioden er der kommet 3 nye fund til (3 kritiske eller høje), og ingen er løst."); + + var backup = ComplianceControl.All.Single(c => c.Name == "Fuld backup"); + var integrity = ComplianceControl.All.Single(c => c.Name == "Integritetstjek"); + Assert.Equal(ControlStatus.NotMet, backup.StatusOn("prod-sql01", data.RuleStates)); + Assert.Equal(ControlStatus.Met, backup.StatusOn("legacy-sql05", data.RuleStates)); + Assert.Equal(ControlStatus.Met, integrity.StatusOn("prod-sql01", data.RuleStates)); + Assert.Equal(ControlStatus.NotMeasured, integrity.StatusOn("legacy-sql05", data.RuleStates)); + Assert.Equal(ControlStatus.NotMeasured, ComplianceControl.All.Single(c => c.Name == "Log-backup").StatusOn("prod-sql01", data.RuleStates)); + } + + [Fact] + public async Task Web_serves_the_report_as_pdf() + { + await using var web = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "None"); + b.UseSetting("SQLInsight:Web:Organization", "Nordisk Logistik A/S"); + }); + + var response = await web.CreateClient().GetAsync("/rapporter/driftsrapport.pdf?dage=90"); + var page = await web.CreateClient().GetStringAsync("/rapporter"); + + Assert.Equal("application/pdf", response.Content.Headers.ContentType!.MediaType); + Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(await response.Content.ReadAsByteArrayAsync(), 0, 5)); + Assert.Contains("Nordisk Logistik A/S", page, StringComparison.Ordinal); + } + + [Fact] + public async Task Monthly_report_is_mailed_once_with_the_pdf_attached() + { + var mailpit = new ContainerBuilder("axllent/mailpit:latest") + .WithPortBinding(1025, true) + .WithPortBinding(8025, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8025).ForPath("/livez"))) + .Build(); + await mailpit.StartAsync(); + var data = Path.Combine(Path.GetTempPath(), "sqlinsight-report-" + Guid.NewGuid().ToString("N")); + try + { + var notifications = new NotificationOptions(); + notifications.Email.Host = mailpit.Hostname; + notifications.Email.Port = mailpit.GetMappedPublicPort(1025); + notifications.Email.Security = "None"; + notifications.Email.From = "sqlinsight@test.local"; + var reports = new ReportOptions { Organization = "Nordisk Logistik A/S", TimeZone = "Europe/Copenhagen" }; + reports.Monthly.Enabled = true; + reports.Monthly.To.Add("it-chef@test.local"); + var service = new MonthlyReportService(Options.Create(reports), Options.Create(notifications), Options.Create(new UpdateOptions { DataDirectory = data }), + _repository, TimeProvider.System, NullLogger.Instance); + + Assert.True(await service.RunOnceAsync(CancellationToken.None)); + Assert.False(await service.RunOnceAsync(CancellationToken.None)); + + using var http = new HttpClient { BaseAddress = new Uri($"http://{mailpit.Hostname}:{mailpit.GetMappedPublicPort(8025)}/") }; + var messages = (await http.GetFromJsonAsync("api/v1/messages")).GetProperty("messages"); + Assert.Equal(1, messages.GetArrayLength()); + Assert.StartsWith("SQLInsight: Driftsrapport for Nordisk Logistik A/S, ", messages[0].GetProperty("Subject").GetString(), StringComparison.Ordinal); + var message = await http.GetFromJsonAsync($"api/v1/message/{messages[0].GetProperty("ID").GetString()}"); + var attachment = message.GetProperty("Attachments")[0]; + Assert.Equal("application/pdf", attachment.GetProperty("ContentType").GetString()); + Assert.Contains("kritisk fund", message.GetProperty("Text").GetString(), StringComparison.Ordinal); + } + finally + { + await mailpit.DisposeAsync(); + if (Directory.Exists(data)) + { + Directory.Delete(data, recursive: true); + } + } + } +} diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj index b20412f..da590b8 100644 --- a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj +++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj @@ -16,6 +16,7 @@ +