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.
+
+ Seneste 30 dage
+ Seneste kvartal
+ Seneste år
+
+ 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 @@
+