>();
+ foreach (var back in weeks)
+ {
+ earlierWaits.Add(await repository.GetWaitsHourlyAsync(id, from - back, to - back, cancellationToken).ConfigureAwait(false));
+ }
+
+ return new TimeWindowAnalysis
+ {
+ Instance = instance,
+ From = from,
+ To = to,
+ Resolution = resolution,
+ RawDays = rawDays,
+ Metrics = metrics,
+ Waits = WindowBaseline.CompareWaits(windowWaits, minutes, earlierWaits),
+ Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false),
+ Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false),
+ Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false),
+ Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false),
+ QueryOrder = order,
+ TopQueries = await repository.GetTopQueriesAsync(id, from, to, order, 25, cancellationToken).ConfigureAwait(false),
+ };
+ }
+}
diff --git a/src/SQLInsight.Reports/TimeWindowReport.cs b/src/SQLInsight.Reports/TimeWindowReport.cs
new file mode 100644
index 0000000..0edc584
--- /dev/null
+++ b/src/SQLInsight.Reports/TimeWindowReport.cs
@@ -0,0 +1,291 @@
+using System.Globalization;
+using MigraDoc.DocumentObjectModel;
+using MigraDoc.DocumentObjectModel.Tables;
+using MigraDoc.Rendering;
+using SQLInsight.Repository;
+
+namespace SQLInsight.Reports;
+
+///
+/// The time window analysis as PDF: load, waits against the normal level, top SQL, I/O, blocking, deadlocks and the
+/// findings active in the window. Contains statement texts (customer data): it is produced locally and not sent anywhere.
+///
+public static class TimeWindowReport
+{
+ private const int MaxTextLength = 300;
+
+ private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
+ 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 Unusual = new(0xD9, 0x2D, 0x20);
+
+ public static byte[] Render(TimeWindowAnalysis analysis, string organization, TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(analysis);
+ ArgumentNullException.ThrowIfNull(zone);
+ InterFontResolver.Install();
+
+ var document = new Document();
+ document.Info.Title = $"Analyse af {analysis.Instance.Name} {Time(analysis.From, zone)} – {Time(analysis.To, zone)}";
+ 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);
+ section.PageSetup.BottomMargin = Unit.FromCentimeter(2);
+ section.PageSetup.LeftMargin = Unit.FromCentimeter(1.8);
+ section.PageSetup.RightMargin = Unit.FromCentimeter(1.8);
+
+ var header = section.Headers.Primary.AddParagraph($"SQLInsight Pro · {organization}");
+ header.Style = "Muted";
+ var footer = section.Footers.Primary.AddParagraph($"Analyse af {analysis.Instance.Name} Side ");
+ footer.Style = "Muted";
+ footer.AddPageField();
+ footer.AddText(" af ");
+ footer.AddNumPagesField();
+
+ var title = section.AddParagraph($"Analyse af {analysis.Instance.Name}");
+ title.Format.Font.Size = 20;
+ title.Format.Font.Bold = true;
+ var period = section.AddParagraph($"{Time(analysis.From, zone)} – {Time(analysis.To, zone)} · {ResolutionText(analysis)}");
+ period.Format.Font.Color = Brand;
+ section.AddParagraph($"Sammenlignet med samme klokkeslæt og ugedag de {WindowBaseline.Weeks} foregående uger. Røde værdier afviger tydeligt fra det normale.", "Muted");
+
+ if (!analysis.HasData)
+ {
+ section.AddParagraph(NoDataText(analysis));
+ return Save(document);
+ }
+
+ Metrics(section, analysis);
+ Waits(section, analysis);
+ TopQueries(section, analysis);
+ Files(section, analysis);
+ Blocking(section, analysis, zone);
+ Deadlocks(section, analysis, zone);
+ Findings(section, analysis);
+ return Save(document);
+ }
+
+ /// Why a window has no data; shared with the web page.
+ public static string NoDataText(TimeWindowAnalysis analysis)
+ {
+ ArgumentNullException.ThrowIfNull(analysis);
+ return "Der er ingen målinger i dette tidsrum. Data findes kun fra SQLInsight Pro blev installeret og inden for opbevaringstiden: "
+ + $"målinger hvert 5. minut i {analysis.RawDays} dage, timeværdier i 400 dage og blocking og deadlocks i 90 dage (standard; se SQLInsight:Retention).";
+ }
+
+ public static string ResolutionText(TimeWindowAnalysis analysis)
+ {
+ ArgumentNullException.ThrowIfNull(analysis);
+ return analysis.Resolution == WindowResolution.Raw
+ ? "målinger hvert 5. minut"
+ : $"timeværdier (tidsrummet ligger før de seneste {analysis.RawDays} dage, så detaljerne er slettet)";
+ }
+
+ private static void Metrics(Section section, TimeWindowAnalysis analysis)
+ {
+ section.AddParagraph("Belastning", StyleNames.Heading1);
+ var table = Table(section, [6.5, 2.5, 2.5, 2.5, 3.4]);
+ Header(table, "Måling", "Gns.", "Maks.", "Normalt", "");
+ foreach (var m in analysis.Metrics.Where(m => m.Points.Count > 0))
+ {
+ Row(table, m.IsUnusual, m.Label, Number(m.Average, m.Decimals) + m.Unit, Number(m.Peak, m.Decimals) + m.Unit,
+ m.NormalAverage is { } n ? Number(n, m.Decimals) + m.Unit : "–", m.IsUnusual ? "afviger" : "");
+ }
+ }
+
+ private static void Waits(Section section, TimeWindowAnalysis analysis)
+ {
+ section.AddParagraph("Største ventetider", StyleNames.Heading1);
+ if (analysis.Waits.Count == 0)
+ {
+ section.AddParagraph("Ingen ventetider målt i tidsrummet.", "Muted");
+ return;
+ }
+
+ var table = Table(section, [6.5, 2.6, 2.6, 2.6, 3.1]);
+ Header(table, "Wait type", "Ventetid", "Pr. minut", "Normalt", "Andel");
+ foreach (var w in analysis.Waits.Take(15))
+ {
+ Row(table, w.IsUnusual, w.WaitType, Number(w.WaitTimeMs / 1000.0, 1) + " s", Number(w.PerMinute / 1000.0, 2) + " s",
+ w.NormalPerMinute is { } n ? Number(n / 1000.0, 2) + " s" : "–", Number(100 * w.Share, 0) + " %");
+ }
+ }
+
+ private static void TopQueries(Section section, TimeWindowAnalysis analysis)
+ {
+ section.AddParagraph("Tungeste forespørgsler", StyleNames.Heading1);
+ if (analysis.TopQueries.Count == 0)
+ {
+ section.AddParagraph("Ingen forespørgselsstatistik i tidsrummet (indsamles fra version 1.0.3 og gemmes som de øvrige 5-minutters målinger).", "Muted");
+ return;
+ }
+
+ var table = Table(section, [9.3, 2, 2, 2.2, 1.9]);
+ Header(table, "Forespørgsel", "CPU", "Varighed", "Læsninger", "Kørsler");
+ foreach (var q in analysis.TopQueries.Take(15))
+ {
+ var row = table.AddRow();
+ var text = q.Text is { Length: > MaxTextLength } t ? t[..MaxTextLength] + " …" : q.Text ?? "(teksten er ikke længere i plan cache)";
+ var cell = row.Cells[0].AddParagraph(q.DatabaseName ?? "");
+ cell.Style = "Muted";
+ var code = row.Cells[0].AddParagraph(text);
+ code.Format.Font.Size = 7;
+ row.Cells[1].AddParagraph(Number(q.WorkerTimeUs / 1_000_000.0, 1) + " s");
+ row.Cells[2].AddParagraph(Number(q.ElapsedTimeUs / 1_000_000.0, 1) + " s");
+ row.Cells[3].AddParagraph(Number(q.LogicalReads, 0));
+ row.Cells[4].AddParagraph(Number(q.Executions, 0));
+ }
+ }
+
+ private static void Files(Section section, TimeWindowAnalysis analysis)
+ {
+ section.AddParagraph("I/O-latens pr. fil", StyleNames.Heading1);
+ if (analysis.Files.Count == 0)
+ {
+ section.AddParagraph("Ingen I/O-målinger i tidsrummet.", "Muted");
+ return;
+ }
+
+ var table = Table(section, [6.5, 1.5, 2.6, 2.2, 2.6, 2]);
+ Header(table, "Database", "Type", "Læsninger", "Gns. læs", "Skrivninger", "Gns. skriv");
+ foreach (var f in analysis.Files.Take(15))
+ {
+ var slow = f.AverageReadMs >= 20 || f.AverageWriteMs >= (f.FileType == "LOG" ? 5 : 20);
+ Row(table, slow, $"{f.DatabaseName} ({f.FileId})", f.FileType, Number(f.Reads, 0), Ms(f.AverageReadMs), Number(f.Writes, 0), Ms(f.AverageWriteMs));
+ }
+ }
+
+ private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
+ {
+ section.AddParagraph("Blocking", StyleNames.Heading1);
+ if (analysis.Blocking.Count == 0)
+ {
+ section.AddParagraph("Ingen blocking over 5 sekunder i tidsrummet.", "Muted");
+ return;
+ }
+
+ var table = Table(section, [3.5, 6.4, 2.7, 2.6, 2.2]);
+ Header(table, "Tidspunkt", "Head blocker", "Blokerede", "Længste", "");
+ foreach (var b in analysis.Blocking.Take(25))
+ {
+ var head = b.Sessions.Count > 0 ? b.Sessions[0] : null;
+ Row(table, false, Time(new DateTimeOffset(DateTime.SpecifyKind(b.SampledAt, DateTimeKind.Utc)), zone),
+ $"session {b.HeadSessionId} {head?.ProgramName} {head?.LoginName}".Trim(), b.BlockedCount.ToString(Danish), Number(b.MaxWaitMs / 1000.0, 0) + " s", "");
+ }
+ }
+
+ private static void Deadlocks(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
+ {
+ section.AddParagraph("Deadlocks", StyleNames.Heading1);
+ if (analysis.Deadlocks.Count == 0)
+ {
+ section.AddParagraph("Ingen deadlocks i tidsrummet.", "Muted");
+ return;
+ }
+
+ var table = Table(section, [3.5, 13.9]);
+ Header(table, "Tidspunkt", "Offer");
+ foreach (var d in analysis.Deadlocks.Take(25))
+ {
+ var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
+ Row(table, false, Time(new DateTimeOffset(DateTime.SpecifyKind(d.OccurredAt, DateTimeKind.Utc)), zone), $"session {victim?.SessionId} {victim?.ClientApp}".Trim());
+ }
+ }
+
+ private static void Findings(Section section, TimeWindowAnalysis analysis)
+ {
+ section.AddParagraph("Fund, der var aktive i tidsrummet", StyleNames.Heading1);
+ if (analysis.Findings.Count == 0)
+ {
+ section.AddParagraph("Ingen.", "Muted");
+ return;
+ }
+
+ var table = Table(section, [2.4, 12.6, 2.4]);
+ Header(table, "Alvor", "Fund", "Regel");
+ foreach (var f in analysis.Findings.Take(40))
+ {
+ Row(table, false, f.Severity.ToString(), f.Title, f.RuleId);
+ }
+ }
+
+ private static void Styles(Document document)
+ {
+ var normal = document.Styles[StyleNames.Normal]!;
+ normal.Font.Name = InterFontResolver.Family;
+ normal.Font.Size = 9;
+ normal.Font.Color = Text;
+ normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(3);
+
+ var heading = document.Styles[StyleNames.Heading1]!;
+ heading.Font.Size = 13;
+ heading.Font.Bold = true;
+ heading.ParagraphFormat.SpaceBefore = Unit.FromPoint(14);
+ heading.ParagraphFormat.SpaceAfter = Unit.FromPoint(5);
+ heading.ParagraphFormat.KeepWithNext = true;
+
+ var muted = document.Styles.AddStyle("Muted", StyleNames.Normal);
+ muted.Font.Color = Muted;
+ muted.Font.Size = 8;
+ }
+
+ private static Table Table(Section section, double[] widthsCm)
+ {
+ var table = section.AddTable();
+ table.Borders.Bottom = new Border { Width = 0.5, Color = Border };
+ foreach (var width in widthsCm)
+ {
+ table.AddColumn(Unit.FromCentimeter(width));
+ }
+
+ return table;
+ }
+
+ private static void Header(Table table, params string[] titles)
+ {
+ var row = table.AddRow();
+ row.HeadingFormat = true;
+ row.Shading.Color = Surface;
+ row.Format.Font.Bold = true;
+ for (var i = 0; i < titles.Length; i++)
+ {
+ row.Cells[i].AddParagraph(titles[i]);
+ }
+ }
+
+ private static void Row(Table table, bool unusual, params string[] values)
+ {
+ var row = table.AddRow();
+ row.Borders.Bottom = new Border { Width = 0.25, Color = Border };
+ for (var i = 0; i < values.Length; i++)
+ {
+ var paragraph = row.Cells[i].AddParagraph(values[i]);
+ if (unusual)
+ {
+ paragraph.Format.Font.Color = Unusual;
+ }
+ }
+ }
+
+ private static string Time(DateTimeOffset utc, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(utc, zone).ToString("dd-MM-yyyy HH:mm", Danish);
+
+ private static string Number(double? value, int decimals) => value is { } v ? v.ToString("N" + decimals, Danish) : "–";
+
+ private static string Ms(double? value) => value is { } v ? v.ToString("N1", Danish) + " ms" : "–";
+
+ private static byte[] Save(Document document)
+ {
+ var renderer = new PdfDocumentRenderer { Document = document };
+ renderer.RenderDocument();
+ using var output = new MemoryStream();
+ renderer.PdfDocument.Save(output, false);
+ return output.ToArray();
+ }
+}
diff --git a/src/SQLInsight.Web/Components/Pages/Analysis.razor b/src/SQLInsight.Web/Components/Pages/Analysis.razor
new file mode 100644
index 0000000..c139f2e
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Pages/Analysis.razor
@@ -0,0 +1,252 @@
+@page "/instanser/{Name}/analyse"
+@using SQLInsight.Reports
+@inject InsightRepository Repository
+@inject Display Display
+
+@if (_instance is null)
+{
+ Ukendt instans · SQLInsight Pro
+ Ukendt instans
+ Der findes ingen instans med navnet @Name.
+ return;
+}
+
+Analyse af @_instance.Name · SQLInsight Pro
+
+Instanser / @_instance.Name / Analyse af tidsrum
+Analyse af tidsrum
+Hvad skete der på @_instance.Name i et bestemt tidsrum: belastning, ventetider, de tungeste forespørgsler, I/O, blocking og deadlocks, sammenlignet med samme klokkeslæt og ugedag de @WindowBaseline.Weeks foregående uger.
+
+
+
+@if (_error is not null)
+{
+ @_error
+ return;
+}
+
+@Display.Time(_from.UtcDateTime) – @Display.Time(_to.UtcDateTime) · @TimeWindowReport.ResolutionText(_analysis!)
+ · Hent som PDF
+
+@if (!_analysis!.HasData)
+{
+ @TimeWindowReport.NoDataText(_analysis)
+ return;
+}
+
+Belastning
+
+ @foreach (var m in _analysis.Metrics.Where(m => m.Points.Count > 0))
+ {
+
+
+
Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")
+
+ }
+
+
+
+ Største ventetider
pr. minut i tidsrummet mod normalen; uden harmløse baggrundsventetider
+ @if (_analysis.Waits.Count == 0)
+ {
+ Ingen ventetider målt i tidsrummet.
+ }
+ else
+ {
+
+ | Wait type | Kategori | Ventetid | Pr. minut | Normalt pr. minut | Andel |
+
+ @foreach (var w in _analysis.Waits.Take(15))
+ {
+
+ | @w.WaitType |
+ @w.Category |
+ @Display.Number(w.WaitTimeMs / 1000.0, 1) s |
+ @Display.Number(w.PerMinute / 1000.0, 2) s |
+ @(w.NormalPerMinute is { } n ? Display.Number(n / 1000.0, 2) + " s" : "–")@(w.Ratio is { } r && w.IsUnusual ? $" (×{Display.Number(r, 1)})" : "") |
+ @Display.Number(100 * w.Share) % |
+
+ }
+
+
+ }
+
+
+
+ Tungeste forespørgsler
summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub
+ @if (_analysis.TopQueries.Count == 0)
+ {
+ Ingen forespørgselsstatistik i tidsrummet. Den indsamles hvert 5. minut fra version 1.0.3 og gemmes i @_analysis.RawDays dage.
+ }
+ else
+ {
+
+ | Forespørgsel | CPU | Varighed | Læsninger | Kørsler |
+
+ @foreach (var q in _analysis.TopQueries)
+ {
+
+ @q.DatabaseName@(q.Text ?? "(teksten er ikke længere i plan cache)") |
+ @Display.Number(q.WorkerTimeUs / 1_000_000.0, 1) s |
+ @Display.Number(q.ElapsedTimeUs / 1_000_000.0, 1) s |
+ @Display.Number(q.LogicalReads) |
+ @Display.Number(q.Executions) |
+
+ }
+
+
+ }
+
+
+
+ I/O-latens pr. fil
+ @if (_analysis.Files.Count == 0)
+ {
+ Ingen I/O-målinger i tidsrummet.
+ }
+ else
+ {
+
+ | Database | Fil | Type | Læsninger | Gns. læs | Skrivninger | Gns. skriv |
+
+ @foreach (var f in _analysis.Files.Take(25))
+ {
+
+ | @f.DatabaseName |
+ @f.FileId |
+ @f.FileType |
+ @Display.Number(f.Reads) |
+ @Ms(f.AverageReadMs) |
+ @Display.Number(f.Writes) |
+ @Ms(f.AverageWriteMs) |
+
+ }
+
+
+ }
+
+
+
+
+ Blocking
+ @if (_analysis.Blocking.Count == 0)
+ {
+ Ingen blocking over 5 sekunder i tidsrummet.
+ }
+ else
+ {
+
+ | Tidspunkt | Head blocker | Blokerede | Længste |
+
+ @foreach (var b in _analysis.Blocking)
+ {
+ var head = b.Sessions.Count > 0 ? b.Sessions[0] : null;
+
+ | @Display.Time(b.SampledAt) |
+ @b.HeadSessionId @head?.Status @head?.ProgramName @head?.LoginName |
+ @b.BlockedCount |
+ @Display.Number(b.MaxWaitMs / 1000.0) s |
+
+ }
+
+
+ }
+
+
+ Deadlocks
+ @if (_analysis.Deadlocks.Count == 0)
+ {
+ Ingen deadlocks i tidsrummet.
+ }
+ else
+ {
+
+ | Tidspunkt | Offer | Graf |
+
+ @foreach (var d in _analysis.Deadlocks)
+ {
+ var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
+
+ | @Display.Time(d.OccurredAt) |
+ session @victim?.SessionId @victim?.ClientApp |
+ .xdl |
+
+ }
+
+
+ }
+
+
+
+
+ Fund, der var aktive i tidsrummet
@_analysis.Findings.Count fund
+
+
+
+@code {
+ private static readonly (string Value, string Label)[] Orders =
+ [("cpu", "CPU"), ("varighed", "Varighed"), ("laesninger", "Læsninger"), ("koersler", "Kørsler")];
+
+ [Parameter] public string Name { get; set; } = string.Empty;
+ [SupplyParameterFromQuery(Name = "fra")] public string? Fra { get; set; }
+ [SupplyParameterFromQuery(Name = "til")] public string? Til { get; set; }
+ [SupplyParameterFromQuery(Name = "forvalg")] public string? Forvalg { get; set; }
+ [SupplyParameterFromQuery(Name = "sortering")] public string? Sortering { get; set; }
+
+ private InstanceOverview? _instance;
+ private TimeWindowAnalysis? _analysis;
+ private DateTimeOffset _from, _to;
+ private string? _error;
+
+ private string InstanceUrl => "/instanser/" + Uri.EscapeDataString(_instance!.Name);
+
+ private string PresetUrl(string preset) => $"{InstanceUrl}/analyse?forvalg={preset}&sortering={Uri.EscapeDataString(Sortering ?? "cpu")}";
+
+ private string PdfUrl => $"/analyse.pdf?instans={Uri.EscapeDataString(_instance!.Name)}&fra={Uri.EscapeDataString(TimeWindow.ToInput(_from, Display.Zone))}"
+ + $"&til={Uri.EscapeDataString(TimeWindow.ToInput(_to, Display.Zone))}&sortering={Uri.EscapeDataString(Sortering ?? "cpu")}";
+
+ protected override async Task OnInitializedAsync()
+ {
+ _instance = await Repository.GetInstanceAsync(Name);
+ if (_instance is null)
+ {
+ return;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ if (TimeWindow.Preset(Forvalg, now, Display.Zone) is { } preset)
+ {
+ (_from, _to) = preset;
+ }
+ else if (Fra is null && Til is null)
+ {
+ (_from, _to) = (now.AddHours(-1), now);
+ }
+ else if (!TimeWindow.TryParse(Fra, Til, Display.Zone, out _from, out _to, out _error))
+ {
+ (_from, _to) = (now.AddHours(-1), now);
+ return;
+ }
+
+ _analysis = await TimeWindowAnalysis.LoadAsync(Repository, _instance, _from, _to, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(Sortering));
+ }
+
+ private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
+}
diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor
index c150f26..9151969 100644
--- a/src/SQLInsight.Web/Components/Pages/Instance.razor
+++ b/src/SQLInsight.Web/Components/Pages/Instance.razor
@@ -36,7 +36,10 @@
-Belastning, seneste 24 timer
+
@@ -116,7 +119,7 @@ else
{
var head = b.Sessions.FirstOrDefault();
- | @Display.Time(b.SampledAt) |
+ @Display.Time(b.SampledAt) |
@b.HeadSessionId @head?.Status @head?.ProgramName |
@b.BlockedCount |
@Display.Number(b.MaxWaitMs / 1000.0) s |
@@ -141,7 +144,7 @@ else
{
var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
- | @Display.Time(d.OccurredAt) |
+ @Display.Time(d.OccurredAt) |
session @victim?.SessionId @victim?.ClientApp |
.xdl |
@@ -231,5 +234,19 @@ else
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
+ ///
The time window analysis: the last hour, or half an hour on each side of an event.
+ private string AnalysisUrl(DateTime? around)
+ {
+ var url = "/instanser/" + Uri.EscapeDataString(_instance!.Name) + "/analyse";
+ if (around is not { } at)
+ {
+ return url;
+ }
+
+ var (from, to) = SQLInsight.Reports.TimeWindow.Around(at);
+ return url + "?fra=" + Uri.EscapeDataString(SQLInsight.Reports.TimeWindow.ToInput(from, Display.Zone))
+ + "&til=" + Uri.EscapeDataString(SQLInsight.Reports.TimeWindow.ToInput(to, Display.Zone));
+ }
+
private static string LatencyClass(double? value, double threshold) => value >= threshold ? "bad" : string.Empty;
}
diff --git a/src/SQLInsight.Web/Display.cs b/src/SQLInsight.Web/Display.cs
index bc394be..d5ec7b9 100644
--- a/src/SQLInsight.Web/Display.cs
+++ b/src/SQLInsight.Web/Display.cs
@@ -16,6 +16,9 @@ public sealed class Display(IOptions
options)
? TimeZoneInfo.FindSystemTimeZoneById(id)
: TimeZoneInfo.Local;
+ /// The time zone times are shown in (SQLInsight:Web:TimeZone, or the server's).
+ public TimeZoneInfo Zone => _zone;
+
public string Time(DateTime? utc) => utc is { } value
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(value, DateTimeKind.Utc), _zone).ToString("dd-MM-yyyy HH:mm", Danish)
: "–";
diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs
index bf828ab..9b4fa12 100644
--- a/src/SQLInsight.Web/Program.cs
+++ b/src/SQLInsight.Web/Program.cs
@@ -112,6 +112,27 @@ app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository r
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");
});
+
+// Time window analysis as PDF. Contains statement texts: produced here, for the person who is logged in.
+app.MapGet("/analyse.pdf", async (string? instans, string? fra, string? til, string? sortering, InsightRepository repository, Display display,
+ Microsoft.Extensions.Options.IOptions options, CancellationToken ct) =>
+{
+ if (instans is null || await repository.GetInstanceAsync(instans, ct) is not { } instance)
+ {
+ return Results.NotFound();
+ }
+
+ if (!SQLInsight.Reports.TimeWindow.TryParse(fra, til, display.Zone, out var from, out var to, out var error))
+ {
+ return Results.BadRequest(error);
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var analysis = await SQLInsight.Reports.TimeWindowAnalysis.LoadAsync(repository, instance, from, to, now,
+ new RetentionOptions().RawDays, SQLInsight.Reports.AnalysisOrder.Parse(sortering), ct);
+ var file = $"analyse-{string.Concat(instance.Name.Select(c => char.IsLetterOrDigit(c) || c == '-' ? c : '_'))}-{SQLInsight.Reports.TimeWindow.ToInput(from, display.Zone).Replace(':', '-')}.pdf";
+ return Results.File(SQLInsight.Reports.TimeWindowReport.Render(analysis, options.Value.Organization, display.Zone), "application/pdf", file);
+});
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/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css
index 1b69c85..787ba35 100644
--- a/src/SQLInsight.Web/wwwroot/app.css
+++ b/src/SQLInsight.Web/wwwroot/app.css
@@ -175,3 +175,16 @@ a.button:hover { filter: brightness(1.08); }
.context-menu { position: absolute; z-index: 1000; min-width: 12rem; padding: .3rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); }
.context-menu a { display: block; padding: .45rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; }
.context-menu a:hover, .context-menu a:focus { background: var(--surface-2); outline: none; }
+
+/* Time window analysis */
+.section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-top: 1.75rem; }
+.section-head h2.section { margin-top: 0; }
+.window-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem 1rem; }
+.window-form label { display: grid; gap: .25rem; font-size: .85rem; color: var(--text-2); }
+.window-form input, .window-form select { font: inherit; padding: .4rem .5rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); }
+.window-form button.button { border: 0; cursor: pointer; font: inherit; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
+.window-form .presets { display: flex; gap: .75rem; font-size: .9rem; }
+.small { font-size: .8rem; }
+.panel.unusual { border-left: 4px solid var(--critical); }
+tr.unusual td { background: var(--critical-soft); }
+pre.sql { white-space: pre-wrap; word-break: break-word; max-height: 9rem; overflow: auto; margin: .25rem 0 0; font-size: .8rem; }
diff --git a/tests/SQLInsight.Agent.Tests/TimeWindowTests.cs b/tests/SQLInsight.Agent.Tests/TimeWindowTests.cs
new file mode 100644
index 0000000..84a7b5b
--- /dev/null
+++ b/tests/SQLInsight.Agent.Tests/TimeWindowTests.cs
@@ -0,0 +1,133 @@
+using SQLInsight.Core.Data;
+using SQLInsight.Reports;
+using SQLInsight.Repository;
+
+namespace SQLInsight.Agent.Tests;
+
+public class TimeWindowTests
+{
+ private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
+ private static readonly DateTimeOffset Now = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void Local_input_is_converted_to_utc_in_the_display_time_zone()
+ {
+ // 20 September 2026 is summer time in Copenhagen (UTC+2): 03:00–04:00 local is 01:00–02:00 UTC.
+ Assert.True(TimeWindow.TryParse("2026-09-20T03:00", "2026-09-20T04:00", Copenhagen, out var from, out var to, out var error));
+
+ Assert.Null(error);
+ Assert.Equal(new DateTimeOffset(2026, 9, 20, 1, 0, 0, TimeSpan.Zero), from);
+ Assert.Equal(new DateTimeOffset(2026, 9, 20, 2, 0, 0, TimeSpan.Zero), to);
+ Assert.Equal("2026-09-20T03:00", TimeWindow.ToInput(from, Copenhagen));
+ }
+
+ [Fact]
+ public void Winter_time_and_the_skipped_summer_time_hour_are_handled()
+ {
+ Assert.True(TimeWindow.TryParse("2026-12-01T03:00", "2026-12-01T04:00", Copenhagen, out var from, out _, out _));
+ Assert.Equal(new DateTimeOffset(2026, 12, 1, 2, 0, 0, TimeSpan.Zero), from);
+
+ // 29 March 2026 02:30 does not exist in Copenhagen: the hour after is used rather than failing.
+ Assert.True(TimeWindow.TryParse("2026-03-29T02:30", "2026-03-29T05:00", Copenhagen, out var skipped, out _, out _));
+ Assert.Equal(new DateTimeOffset(2026, 3, 29, 1, 30, 0, TimeSpan.Zero), skipped);
+ }
+
+ [Theory]
+ [InlineData("2026-09-20T04:00", "2026-09-20T03:00", "Til skal ligge efter fra.")]
+ [InlineData("2026-08-01T00:00", "2026-09-20T03:00", "Højst 31 dage ad gangen.")]
+ [InlineData("i går", "2026-09-20T03:00", "Angiv fra og til som dato og klokkeslæt.")]
+ public void Invalid_windows_are_refused_with_a_reason(string from, string to, string expected)
+ {
+ Assert.False(TimeWindow.TryParse(from, to, Copenhagen, out _, out _, out var error));
+ Assert.Equal(expected, error);
+ }
+
+ [Fact]
+ public void Raw_samples_are_used_inside_their_retention_and_hourly_history_before()
+ {
+ Assert.Equal(WindowResolution.Raw, TimeWindow.ChooseResolution(Now.AddDays(-13), Now, rawDays: 14));
+ Assert.Equal(WindowResolution.Hourly, TimeWindow.ChooseResolution(Now.AddDays(-15), Now, rawDays: 14));
+ }
+
+ [Fact]
+ public void Presets_follow_the_local_calendar()
+ {
+ // 10:00 UTC = 12:00 in Copenhagen.
+ var (nightFrom, nightTo) = TimeWindow.Preset("i-nat", Now, Copenhagen)!.Value;
+ Assert.Equal(new DateTimeOffset(2026, 9, 30, 22, 0, 0, TimeSpan.Zero), nightFrom);
+ Assert.Equal(new DateTimeOffset(2026, 10, 1, 4, 0, 0, TimeSpan.Zero), nightTo);
+
+ var (yesterdayFrom, yesterdayTo) = TimeWindow.Preset("i-gaar", Now, Copenhagen)!.Value;
+ Assert.Equal(new DateTimeOffset(2026, 9, 29, 22, 0, 0, TimeSpan.Zero), yesterdayFrom);
+ Assert.Equal(new DateTimeOffset(2026, 9, 30, 22, 0, 0, TimeSpan.Zero), yesterdayTo);
+
+ // Before 06 local, "i nat" ends now.
+ var early = new DateTimeOffset(2026, 10, 1, 2, 0, 0, TimeSpan.Zero);
+ Assert.Equal(early, TimeWindow.Preset("i-nat", early, Copenhagen)!.Value.To);
+ Assert.Null(TimeWindow.Preset("ukendt", Now, Copenhagen));
+ }
+
+ [Fact]
+ public void Waits_are_compared_per_minute_with_the_weeks_that_have_history()
+ {
+ var window = new List
+ {
+ new("LCK_M_X", 50, 600_000, 0), // 10 min in a 60-minute window = 10 000 ms/min
+ new("PAGEIOLATCH_SH", 1_000, 60_000, 0), // 1 min = 1 000 ms/min
+ };
+ IReadOnlyList> weeks =
+ [
+ [new("LCK_M_X", 60_000, 10, 1), new("PAGEIOLATCH_SH", 60_000, 900, 1)], // 1 000 ms/min each
+ [new("LCK_M_X", 180_000, 10, 1), new("PAGEIOLATCH_SH", 60_000, 900, 1)], // 3 000 and 1 000
+ [], // no data that week: ignored
+ ];
+
+ var result = WindowBaseline.CompareWaits(window, 60, weeks);
+
+ var locks = result[0];
+ Assert.Equal("LCK_M_X", locks.WaitType);
+ Assert.Equal(10_000, locks.PerMinute);
+ Assert.Equal(2_000, locks.NormalPerMinute);
+ Assert.Equal(5, locks.Ratio);
+ Assert.True(locks.IsUnusual);
+
+ var io = result[1];
+ Assert.Equal(1_000, io.NormalPerMinute);
+ Assert.False(io.IsUnusual);
+ }
+
+ [Fact]
+ public void Without_history_the_normal_is_unknown_and_nothing_is_flagged_by_ratio()
+ {
+ var result = WindowBaseline.CompareWaits([new WaitTotal("CXPACKET", 1, 1_000, 0)], 60, [[], []]);
+
+ Assert.Null(result[0].NormalPerMinute);
+ Assert.Null(result[0].Ratio);
+ Assert.False(result[0].IsUnusual);
+ }
+
+ [Fact]
+ public void Metric_is_unusual_when_clearly_worse_than_normal()
+ {
+ var at = Now.UtcDateTime;
+ var cpu = new MetricComparison("cpu", "CPU", " %", 0, LowerIsWorse: false, 100, [new MetricPoint(at, 80), new MetricPoint(at.AddMinutes(5), 90)],
+ WindowBaseline.NormalAverage([[new MetricPoint(at.AddDays(-7), 30)], [new MetricPoint(at.AddDays(-14), 50)]]));
+ Assert.Equal(40, cpu.NormalAverage);
+ Assert.True(cpu.IsUnusual);
+
+ var ple = new MetricComparison("ple", "PLE", "", 0, LowerIsWorse: true, null, [new MetricPoint(at, 200)], 1_000);
+ Assert.True(ple.IsUnusual);
+
+ var normal = new MetricComparison("cpu", "CPU", " %", 0, false, 100, [new MetricPoint(at, 42)], 40);
+ Assert.False(normal.IsUnusual);
+ Assert.Null(WindowBaseline.NormalAverage([[], []]));
+ }
+
+ [Theory]
+ [InlineData("varighed", TopQueryOrder.Duration)]
+ [InlineData("laesninger", TopQueryOrder.Reads)]
+ [InlineData("koersler", TopQueryOrder.Executions)]
+ [InlineData(null, TopQueryOrder.Cpu)]
+ [InlineData("noget-andet", TopQueryOrder.Cpu)]
+ public void Order_comes_from_the_url(string? value, TopQueryOrder expected) => Assert.Equal(expected, AnalysisOrder.Parse(value));
+}
diff --git a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs
new file mode 100644
index 0000000..778552c
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs
@@ -0,0 +1,218 @@
+using Dapper;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Data.SqlClient;
+using SQLInsight.Collectors.Performance;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Instances;
+using SQLInsight.Reports;
+using SQLInsight.Repository;
+
+namespace SQLInsight.IntegrationTests;
+
+///
+/// Top SQL against real SQL Server (as the least-privilege agent), and the time window analysis on stored data:
+/// repository queries, the web page and the PDF.
+///
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime
+{
+ private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
+
+ private readonly string _database = "SQLInsightWindow_" + Guid.NewGuid().ToString("N")[..8];
+ private InsightRepository _repository = null!;
+ private int _instanceId;
+
+ public async Task InitializeAsync()
+ {
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
+ _repository = new InsightRepository(sql.DatabaseConnectionString(_database));
+ await _repository.MigrateAsync();
+ var info = new InstanceInfo
+ {
+ ServerName = "AX01",
+ Version = SqlServerVersion.Parse("16.0.4135.4"),
+ ProductLevel = "RTM",
+ Edition = "Standard Edition",
+ EngineEdition = EngineEdition.Standard,
+ };
+ _instanceId = await _repository.UpsertInstanceAsync(@"AX01\AX30", info, "SqlClient", false, DateTimeOffset.UtcNow);
+ }
+
+ public async Task DisposeAsync() =>
+ await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
+
+ [Fact]
+ public async Task Collector_turns_query_stats_into_deltas_with_text()
+ {
+ var table = "QsProbe_" + Guid.NewGuid().ToString("N")[..8];
+ await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; CREATE TABLE dbo.{table} (Id int NOT NULL PRIMARY KEY, Amount money NOT NULL); INSERT dbo.{table} SELECT TOP (2000) ROW_NUMBER() OVER (ORDER BY (SELECT 1)), 1 FROM sys.all_columns a CROSS JOIN sys.all_columns b;");
+ var probe = $"SELECT SUM(p.Amount) FROM dbo.{table} p JOIN dbo.{table} q ON q.Id = p.Id WHERE p.Id > 0";
+
+ await using var session = await AgentSession.OpenAsync(sql);
+ var collector = new QueryStatsCollector();
+ var first = await session.CollectAsync>(collector);
+ await Task.Delay(1100);
+ for (var i = 0; i < 5; i++)
+ {
+ await RunInTestDatabaseAsync(probe);
+ }
+
+ var second = await session.CollectAsync(collector, first);
+ await Task.Delay(1100);
+ for (var i = 0; i < 3; i++)
+ {
+ await RunInTestDatabaseAsync(probe);
+ }
+
+ var third = await session.CollectAsync(collector, second);
+
+ Assert.Null(first.Delta);
+ // Compiled after the first collection: all five executions are new.
+ var compiled = Assert.Single(second.Delta!.Queries, q => q.Text?.Contains(table, StringComparison.Ordinal) == true);
+ Assert.Equal(5, compiled.Executions);
+ Assert.True(compiled.WorkerTimeUs > 0);
+ Assert.True(compiled.LogicalReads > 0);
+ Assert.Equal(SqlServerFixture.TestDatabase, compiled.DatabaseName);
+ Assert.StartsWith("0x", compiled.Key, StringComparison.Ordinal);
+ // Same plan: only the difference.
+ Assert.Equal(3, Assert.Single(third.Delta!.Queries, q => q.Key == compiled.Key).Executions);
+ Assert.Contains(third.Snapshot.Queries, q => q.Key == compiled.Key);
+ }
+
+ [Fact]
+ public async Task Repository_sums_top_sql_per_window_and_never_keeps_it_as_a_snapshot()
+ {
+ var now = DateTimeOffset.UtcNow;
+ await SaveQueryDeltaAsync(now.AddMinutes(-20), ("0xAAA", "SELECT * FROM CUSTTABLE", 10, 4_000_000), ("0xBBB", "SELECT * FROM INVENTTRANS", 1, 9_000_000));
+ await SaveQueryDeltaAsync(now.AddMinutes(-10), ("0xAAA", "SELECT * FROM CUSTTABLE", 5, 2_000_000));
+ await SaveQueryDeltaAsync(now.AddHours(-5), ("0xAAA", "SELECT * FROM CUSTTABLE", 100, 100_000_000)); // outside the window
+
+ var byCpu = await _repository.GetTopQueriesAsync(_instanceId, now.AddHours(-1), now);
+ var byExecutions = await _repository.GetTopQueriesAsync(_instanceId, now.AddHours(-1), now, TopQueryOrder.Executions);
+
+ Assert.Equal(["0xBBB", "0xAAA"], byCpu.Select(q => q.QueryKey));
+ var custtable = byCpu.Single(q => q.QueryKey == "0xAAA");
+ Assert.Equal(15, custtable.Executions);
+ Assert.Equal(6_000_000, custtable.WorkerTimeUs);
+ Assert.Equal("SELECT * FROM CUSTTABLE", custtable.Text);
+ Assert.Equal("0xAAA", byExecutions[0].QueryKey);
+
+ await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
+ Assert.Equal(0, await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM insight.Snapshots WHERE Kind = 'query-stats' OR Json LIKE '%CUSTTABLE%'"));
+ }
+
+ [Fact]
+ public async Task Analysis_compares_the_window_with_the_same_hours_in_earlier_weeks()
+ {
+ var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1);
+ var from = to.AddHours(-1);
+ await SeedWindowAsync(from, to);
+
+ var analysis = await TimeWindowAnalysis.LoadAsync(_repository, (await _repository.GetInstanceAsync(@"AX01\AX30"))!, from, to, DateTimeOffset.UtcNow);
+
+ Assert.Equal(WindowResolution.Raw, analysis.Resolution);
+ Assert.True(analysis.HasData);
+ var locks = analysis.Waits.Single(w => w.WaitType == "LCK_M_X");
+ Assert.Equal(1_000, locks.NormalPerMinute); // 60 s per hour in each earlier week
+ Assert.True(locks.IsUnusual);
+ var cpu = analysis.Metrics.Single(m => m.Metric == Metrics.CpuSqlServer);
+ Assert.Equal(90, cpu.Average);
+ Assert.Equal(30, cpu.NormalAverage);
+ Assert.True(cpu.IsUnusual);
+ Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text);
+ Assert.Single(analysis.Blocking);
+ }
+
+ [Fact]
+ public async Task Page_and_pdf_show_the_window_and_explain_an_empty_one()
+ {
+ var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1);
+ var from = to.AddHours(-1);
+ await SeedWindowAsync(from, to);
+ await using var factory = 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:TimeZone", "Europe/Copenhagen");
+ });
+ var client = factory.CreateClient();
+ var name = Uri.EscapeDataString(@"AX01\AX30");
+ static string Window(DateTimeOffset f, DateTimeOffset t) =>
+ $"fra={Uri.EscapeDataString(TimeWindow.ToInput(f, Copenhagen))}&til={Uri.EscapeDataString(TimeWindow.ToInput(t, Copenhagen))}";
+
+ var html = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from, to)}");
+ Assert.Contains("SELECT * FROM SALESLINE", html, StringComparison.Ordinal);
+ Assert.Contains("LCK_M_X", html, StringComparison.Ordinal);
+ Assert.Contains("class=\"unusual\"", html, StringComparison.Ordinal);
+ Assert.Contains("value=\"" + TimeWindow.ToInput(from, Copenhagen) + "\"", html, StringComparison.Ordinal);
+
+ var empty = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from.AddDays(-200), to.AddDays(-200))}");
+ Assert.Contains("Der er ingen målinger i dette tidsrum", empty, StringComparison.Ordinal);
+
+ var invalid = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(to, from)}");
+ Assert.Contains("Til skal ligge efter fra.", invalid, StringComparison.Ordinal);
+
+ var instancePage = await client.GetStringAsync($"/instanser/{name}");
+ Assert.Contains($"/instanser/{name}/analyse", instancePage, StringComparison.Ordinal);
+
+ using var pdf = await client.GetAsync($"/analyse.pdf?instans={name}&{Window(from, to)}");
+ Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
+ Assert.StartsWith("%PDF", System.Text.Encoding.ASCII.GetString(await pdf.Content.ReadAsByteArrayAsync())[..4], StringComparison.Ordinal);
+ }
+
+ /// A busy hour: high CPU, lock waits and one heavy statement, against quiet same hours four weeks back.
+ private async Task SeedWindowAsync(DateTimeOffset from, DateTimeOffset to)
+ {
+ await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
+ for (var at = from.AddMinutes(5); at <= to; at = at.AddMinutes(5))
+ {
+ await connection.ExecuteAsync("""
+ INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES (@id, @cpu, @at, 90), (@id, @batches, @at, 450);
+ INSERT insight.WaitSamples (InstanceId, SampledAt, WaitType, IntervalSeconds, WaitingTasks, WaitTimeMs, SignalWaitTimeMs)
+ VALUES (@id, @at, 'LCK_M_X', 300, 40, 600000, 0), (@id, @at, 'PAGEIOLATCH_SH', 300, 900, 30000, 100);
+ """, new { id = _instanceId, cpu = Metrics.CpuSqlServer, batches = Metrics.BatchRequestsPerSecond, at = at.UtcDateTime });
+ }
+
+ for (var week = 1; week <= WindowBaseline.Weeks; week++)
+ {
+ var hour = from.AddDays(-7 * week).UtcDateTime;
+ await connection.ExecuteAsync("""
+ INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES (@id, @cpu, @hour, 30, 40, 12);
+ INSERT insight.WaitHourly (InstanceId, [Hour], WaitType, WaitingTasks, WaitTimeMs, SignalWaitTimeMs)
+ VALUES (@id, @hour, 'LCK_M_X', 10, 60000, 0), (@id, @hour, 'PAGEIOLATCH_SH', 10000, 360000, 1000);
+ """, new { id = _instanceId, cpu = Metrics.CpuSqlServer, hour });
+ }
+
+ await connection.ExecuteAsync("""
+ INSERT insight.BlockingEvents (InstanceId, SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson)
+ VALUES (@id, @at, 55, 7, 42000, '[{"SessionId":55,"Status":"sleeping","ProgramName":"Microsoft Dynamics AX","LoginName":"AXSERVICE"}]');
+ """, new { id = _instanceId, at = from.AddMinutes(20).UtcDateTime });
+ await SaveQueryDeltaAsync(from.AddMinutes(30), ("0xCCC", "SELECT * FROM SALESLINE", 4000, 120_000_000));
+ }
+
+ private Task SaveQueryDeltaAsync(DateTimeOffset at, params (string Key, string Text, long Executions, long Cpu)[] queries) =>
+ _repository.SaveCollectedAsync(_instanceId, "query-stats", new CumulativeSample(
+ new QueryStatsSnapshot { CapturedAt = at, ServerTime = at.UtcDateTime, SqlServerStartTime = DateTime.UtcNow.AddDays(-30), Queries = [] },
+ new QueryStatsDelta
+ {
+ From = at.AddMinutes(-5),
+ To = at,
+ Queries = [.. queries.Select(q => new QueryStatDelta
+ {
+ Key = q.Key, Text = q.Text, DatabaseName = "AX30", Executions = q.Executions, WorkerTimeUs = q.Cpu, ElapsedTimeUs = q.Cpu * 2,
+ LogicalReads = q.Executions * 100, PhysicalReads = 0, LogicalWrites = 0,
+ })],
+ }));
+
+ /// Like an application: connected to its own database, so the plan belongs to it.
+ private async Task RunInTestDatabaseAsync(string statement)
+ {
+ await using var connection = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
+ await connection.ExecuteScalarAsync(statement);
+ }
+
+ private static DateTimeOffset FloorHour(DateTimeOffset value) => new(value.Year, value.Month, value.Day, value.Hour, 0, 0, value.Offset);
+}