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
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:
parent
0e2146ef6d
commit
d238580614
27 changed files with 1060 additions and 5 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
37
docs/rapporter.md
Normal 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.
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
124
src/SQLInsight.Agent/Reports/MonthlyReportService.cs
Normal file
124
src/SQLInsight.Agent/Reports/MonthlyReportService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
BIN
src/SQLInsight.Reports/Fonts/Inter-Bold.ttf
Normal file
BIN
src/SQLInsight.Reports/Fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
src/SQLInsight.Reports/Fonts/Inter-Italic.ttf
Normal file
BIN
src/SQLInsight.Reports/Fonts/Inter-Italic.ttf
Normal file
Binary file not shown.
92
src/SQLInsight.Reports/Fonts/Inter-OFL.txt
Normal file
92
src/SQLInsight.Reports/Fonts/Inter-OFL.txt
Normal 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.
|
||||||
BIN
src/SQLInsight.Reports/Fonts/Inter-Regular.ttf
Normal file
BIN
src/SQLInsight.Reports/Fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
40
src/SQLInsight.Reports/InterFontResolver.cs
Normal file
40
src/SQLInsight.Reports/InterFontResolver.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
337
src/SQLInsight.Reports/ManagementReport.cs
Normal file
337
src/SQLInsight.Reports/ManagementReport.cs
Normal 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);
|
||||||
|
}
|
||||||
93
src/SQLInsight.Reports/ReportData.cs
Normal file
93
src/SQLInsight.Reports/ReportData.cs
Normal 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,
|
||||||
|
}
|
||||||
40
src/SQLInsight.Reports/ReportMailer.cs
Normal file
40
src/SQLInsight.Reports/ReportMailer.cs
Normal 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);
|
||||||
|
}
|
||||||
19
src/SQLInsight.Reports/SQLInsight.Reports.csproj
Normal file
19
src/SQLInsight.Reports/SQLInsight.Reports.csproj
Normal 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>
|
||||||
37
src/SQLInsight.Repository/InsightRepository.Reports.cs
Normal file
37
src/SQLInsight.Repository/InsightRepository.Reports.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
18
src/SQLInsight.Web/Components/Pages/Reports.razor
Normal file
18
src/SQLInsight.Web/Components/Pages/Reports.razor
Normal 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>
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
35
tests/SQLInsight.Agent.Tests/MonthlyReportTests.cs
Normal file
35
tests/SQLInsight.Agent.Tests/MonthlyReportTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tests/SQLInsight.IntegrationTests/ReportTests.cs
Normal file
155
tests/SQLInsight.IntegrationTests/ReportTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue