Fase 4: management and compliance reports as PDF
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

A PDF report for management and auditors (PDFsharp/MigraDoc, MIT, with
the Inter font embedded): key figures, a plain-language summary,
compliance controls backed by rules (met, not met, not measured),
instances and the critical and high findings. Downloadable from the
web UI (/rapporter) and mailed monthly by the agent with the PDF
attached, once per month, across daylight saving changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 10:09:10 +00:00
parent 0e2146ef6d
commit d238580614
No known key found for this signature in database
27 changed files with 1060 additions and 5 deletions

View file

@ -21,6 +21,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" /> <PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" /> <PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
<PackageVersion Include="PDFsharp-MigraDoc" Version="6.2.4" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />

View file

@ -204,11 +204,9 @@ Hvert **finding** indeholder:
### Fase 4: Videre ### Fase 4: Videre
- [ ] Anbefalinger på tværs af kunder: "denne CU har løst problemet hos andre" - [ ] 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 - [ ] 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 ## Understøttede versioner

View file

@ -7,6 +7,7 @@
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" /> <Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" /> <Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.csproj" /> <Project Path="src/SQLInsight.Licensing/SQLInsight.Licensing.csproj" />
<Project Path="src/SQLInsight.Reports/SQLInsight.Reports.csproj" />
<Project Path="src/SQLInsight.Updates/SQLInsight.Updates.csproj" /> <Project Path="src/SQLInsight.Updates/SQLInsight.Updates.csproj" />
<Project Path="src/SQLInsight.Notifications/SQLInsight.Notifications.csproj" /> <Project Path="src/SQLInsight.Notifications/SQLInsight.Notifications.csproj" />
<Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" /> <Project Path="src/SQLInsight.Repository/SQLInsight.Repository.csproj" />

37
docs/rapporter.md Normal file
View file

@ -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.

View file

@ -71,6 +71,8 @@ builder.Services.AddSingleton<SQLInsight.Agent.Hub.HubOutbox>();
builder.Services.AddHostedService<SQLInsight.Agent.Hub.HubSyncService>(); builder.Services.AddHostedService<SQLInsight.Agent.Hub.HubSyncService>();
builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(10)); builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(10));
builder.Services.AddHostedService<UpdateService>(); builder.Services.AddHostedService<UpdateService>();
builder.Services.Configure<SQLInsight.Agent.Reports.ReportOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Reports.ReportOptions.SectionName));
builder.Services.AddHostedService<SQLInsight.Agent.Reports.MonthlyReportService>();
builder.Services.AddHostedService<MonitoringService>(); builder.Services.AddHostedService<MonitoringService>();
var host = builder.Build(); var host = builder.Build();

View file

@ -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";
/// <summary>Name on the report, e.g. the company or department.</summary>
public string Organization { get; set; } = "SQL Server-miljøet";
/// <summary>Time zone for dates and for when the month starts, e.g. Europe/Copenhagen. Default: the server's.</summary>
public string? TimeZone { get; set; }
public MonthlyReportOptions Monthly { get; set; } = new();
}
/// <summary>The previous month's report by e-mail, using the SMTP settings under SQLInsight:Notifications:Email.</summary>
public sealed class MonthlyReportOptions
{
public bool Enabled { get; set; }
public IList<string> To { get; } = [];
/// <summary>Day of the month to send on (1–28).</summary>
public int Day { get; set; } = 1;
/// <summary>Local hour to send at.</summary>
public int Hour { get; set; } = 7;
}
public sealed partial class MonthlyReportService(
IOptions<ReportOptions> options,
IOptions<NotificationOptions> notifications,
IOptions<UpdateOptions> updates,
InsightRepository repository,
TimeProvider time,
ILogger<MonthlyReportService> 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));
}
/// <summary>Sends last month's report if it is due and not sent yet. Returns true if it was sent.</summary>
public async Task<bool> 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);
/// <summary>
/// The previous calendar month (in <paramref name="zone"/>) once the send day and hour of the current month has
/// passed, unless the current month's report (<paramref name="lastSent"/> = "yyyy-MM") has already gone out.
/// </summary>
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);
}

View file

@ -13,6 +13,7 @@
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" /> <ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" /> <ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
<ProjectReference Include="..\SQLInsight.Updates\SQLInsight.Updates.csproj" /> <ProjectReference Include="..\SQLInsight.Updates\SQLInsight.Updates.csproj" />
<ProjectReference Include="..\SQLInsight.Reports\SQLInsight.Reports.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />

View file

@ -18,8 +18,13 @@ public sealed class EmailChannel(EmailOptions options) : INotificationChannel
public async Task SendAsync(IReadOnlyList<Notification> notifications, CancellationToken cancellationToken) public async Task SendAsync(IReadOnlyList<Notification> notifications, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(notifications); ArgumentNullException.ThrowIfNull(notifications);
var message = Build(options, notifications); await SendMessageAsync(options, Build(options, notifications), cancellationToken).ConfigureAwait(false);
}
/// <summary>Sends a message through the configured SMTP server (also used for scheduled reports).</summary>
public static async Task SendMessageAsync(EmailOptions options, MimeMessage message, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
using var client = new SmtpClient(); using var client = new SmtpClient();
var security = options.Security.ToUpperInvariant() switch var security = options.Security.ToUpperInvariant() switch
{ {

Binary file not shown.

Binary file not shown.

View file

@ -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.

Binary file not shown.

View file

@ -0,0 +1,40 @@
using PdfSharp.Fonts;
namespace SQLInsight.Reports;
/// <summary>Serves the embedded Inter font to PDFsharp, so no system fonts are needed (Windows or Linux).</summary>
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();
}
}

View file

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

View file

@ -0,0 +1,93 @@
using System.Reflection;
using SQLInsight.Core.Findings;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Reports;
/// <summary>Everything a management report shows, read from the repository in one go.</summary>
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<InstanceOverview> Instances { get; init; }
public required IReadOnlyList<StoredFinding> Active { get; init; }
public required IReadOnlyList<InstanceRuleState> RuleStates { get; init; }
public required PeriodStats Period { get; init; }
public string? RulePack { get; init; }
public string Version { get; init; } =
typeof(ReportData).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.Split('+')[0] ?? "0.0.0";
public int Count(Severity severity) => Active.Count(f => f.Severity == severity);
public IEnumerable<InstanceOverview> Unreachable(TimeSpan window) =>
Instances.Where(i => i.IsFailing || i.LastSeenAt is null || GeneratedAt.UtcDateTime - i.LastSeenAt > window);
public static async Task<ReportData> 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,
};
}
}
/// <summary>
/// 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.
/// </summary>
public sealed record ComplianceControl(string Name, string Description, params string[] Rules)
{
public static readonly IReadOnlyList<ComplianceControl> 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<InstanceRuleState> 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,
}

View file

@ -0,0 +1,40 @@
using System.Globalization;
using MimeKit;
using SQLInsight.Notifications;
namespace SQLInsight.Reports;
/// <summary>Sends the management report as a PDF attachment through the same SMTP server as notifications.</summary>
public static class ReportMailer
{
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
public static MimeMessage Build(EmailOptions smtp, IEnumerable<string> 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);
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Reports</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" />
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PDFsharp-MigraDoc" />
</ItemGroup>
<ItemGroup>
<!-- Inter (SIL Open Font License, see Fonts/Inter-OFL.txt), embedded so reports look the same on every server. -->
<EmbeddedResource Include="Fonts\*.ttf" LogicalName="SQLInsight.Reports.Fonts.%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.IntegrationTests" />
</ItemGroup>
</Project>

View file

@ -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
{
/// <summary>The latest outcome of every rule on every instance.</summary>
public async Task<IReadOnlyList<InstanceRuleState>> 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();
}
/// <summary>Findings opened and resolved between <paramref name="from"/> and <paramref name="to"/>.</summary>
public async Task<PeriodStats> GetPeriodStatsAsync(DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleAsync<PeriodStats>("""
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);
}
}

View file

@ -11,6 +11,7 @@
<span class="nav-label">Overvågning</span> <span class="nav-label">Overvågning</span>
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink> <NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink> <NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink> <NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
<span class="nav-label">Community</span> <span class="nav-label">Community</span>

View file

@ -0,0 +1,18 @@
@page "/rapporter"
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
<PageTitle>Rapporter · SQLInsight Pro</PageTitle>
<h1>Rapporter</h1>
<p class="lead">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.</p>
<section class="panel">
<h2>Driftsrapport for @Options.Value.Organization</h2>
<p>Vælg periode. Nye og løste fund tælles i perioden; aktive fund og kontroller viser situationen nu.</p>
<p class="report-links">
<a class="button" href="/rapporter/driftsrapport.pdf?dage=30">Seneste 30 dage</a>
<a class="button" href="/rapporter/driftsrapport.pdf?dage=90">Seneste kvartal</a>
<a class="button" href="/rapporter/driftsrapport.pdf?dage=365">Seneste år</a>
</p>
<p class="muted">Rapporten kan også sendes automatisk hver måned. Se <code>SQLInsight:Reports</code> i agentens konfiguration.</p>
</section>

View file

@ -98,6 +98,17 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
: Results.NotFound()); : Results.NotFound());
IntegrationEndpoints.Map(app); 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<WebOptions> 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<App>(); app.MapRazorComponents<App>();
// The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader. // The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader.

View file

@ -5,6 +5,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" /> <ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
<ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" /> <ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" />
<ProjectReference Include="..\SQLInsight.Reports\SQLInsight.Reports.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Negotiate" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Negotiate" />

View file

@ -20,4 +20,7 @@ public sealed class WebOptions
/// <summary>Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default.</summary> /// <summary>Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default.</summary>
public bool ApiIncludesSensitiveEvidence { get; set; } public bool ApiIncludesSensitiveEvidence { get; set; }
/// <summary>Name shown on reports, e.g. the company or department.</summary>
public string Organization { get; set; } = "SQL Server-miljøet";
} }

View file

@ -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 { border-top: 1px solid var(--border); padding: .6rem 0; }
details summary { cursor: pointer; font-weight: 500; } details summary { cursor: pointer; font-weight: 500; }
details .script pre { max-height: 32rem; } 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); }

View file

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

View file

@ -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<ReportData> 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<Program>().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<MonthlyReportService>.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<JsonElement>("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<JsonElement>($"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);
}
}
}
}

View file

@ -16,6 +16,7 @@
<ProjectReference Include="..\..\src\SQLInsight.Web\SQLInsight.Web.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Web\SQLInsight.Web.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Notifications\SQLInsight.Notifications.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Hub\SQLInsight.Hub.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Hub\SQLInsight.Hub.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Reports\SQLInsight.Reports.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" /> <None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />