Time window analysis: what happened between 03 and 04, as a page and PDF

SQLInsight Web: /instanser/{navn}/analyse?fra=&til= (datetime-local in the
display time zone; presets last hour, tonight 00-06, yesterday; at most 31
days). "Analysér et tidsrum" on the instance page, and blocking/deadlock
times there open the window half an hour around the event.

For the window: load (CPU, batch requests, compilations, PLE, connections,
memory grants pending, blocked processes) from 5-minute samples within
Retention:RawDays, otherwise hourly history (shown on the page); waits per
minute against the same hours and weekday of the previous 4 weeks, with
clear deviations marked; top SQL sortable by CPU, duration, reads and
executions; file I/O latency; blocking chains; deadlocks; findings active
in the window. An empty window explains the retention. "Hent som PDF"
renders the same via MigraDoc (/analyse.pdf).

Docs: docs/analyse.md (also on the community site); databehandling.md: the
statement text stays in the customer's repository and is never sent to
the hub.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-01 15:46:56 +02:00
parent bb3b38e77f
commit 554a6a71f5
14 changed files with 1270 additions and 4 deletions

45
docs/analyse.md Normal file
View file

@ -0,0 +1,45 @@
# Analyse af tidsrum
Hvad skete der på en instans mellem fx kl. 03 og 04 den 20. september? Siden **Analyse af tidsrum** viser det ud fra det, agenten har gemt, på samme måde som Statspack/AWR i Oracle.
Åbn en instans i webbrugerfladen, og vælg **Analysér et tidsrum**. Tidspunkterne i listerne over blocking og deadlocks på instansens side er også links: de åbner tidsrummet en halv time før og efter hændelsen.
Angiv fra og til (i webbrugerfladens tidszone, `SQLInsight:Web:TimeZone`), eller vælg *Seneste time*, *I nat 00–06* eller *I går*. Højst 31 dage ad gangen. **Hent som PDF** giver det samme som rapport.
## Indhold
| Afsnit | Hvad |
|---|---|
| Belastning | Forløb af CPU (SQL Server og andre processer), batch requests og compilations pr. sekund, page life expectancy, brugerforbindelser, memory grants pending og blokerede processer |
| Største ventetider | Ventetid pr. minut i tidsrummet, sammenlignet med normalen. Harmløse baggrundsventetider er udeladt |
| Tungeste forespørgsler | De forespørgsler, der brugte mest CPU, tid, læsninger eller blev kørt flest gange i tidsrummet, med tekst og database |
| I/O-latens pr. fil | Læsninger, skrivninger og gennemsnitlig latens pr. databasefil |
| Blocking og deadlocks | Blocking-kæder over 5 sekunder (head blocker, antal blokerede, længste ventetid) og deadlocks med graf (.xdl) |
| Fund | Fund, der var aktive på et tidspunkt i tidsrummet |
**Normalen** er samme klokkeslæt og ugedag de 4 foregående uger (timeværdier). En ventetid markeres med rødt, når den er mindst dobbelt så høj som normalt og udgør mindst 5 % af ventetiden i tidsrummet. En måling markeres, når gennemsnittet er mindst 50 % værre end normalt (for page life expectancy: halvt så højt). Uden historik vises normalen som "–".
## Hvor langt tilbage
Kun tidsrum, hvor agenten kørte og data stadig er gemt:
| Data | Opbevaring (standard, `SQLInsight:Retention`) |
|---|---|
| Målinger hvert 5. minut: belastning, ventetider, I/O, forespørgsler | 14 dage (`RawDays`) |
| Timeværdier: belastning og ventetider | 400 dage (`HourlyDays`) |
| Blocking og deadlocks | 90 dage (`EventDays`) |
Ligger tidsrummet længere tilbage end `RawDays`, bruges timeværdier. Det står på siden. Så er der ingen I/O pr. fil og ingen forespørgsler, men belastning og ventetider kan stadig sammenlignes.
## Tungeste forespørgsler
Agenten læser `sys.dm_exec_query_stats` hvert 5. minut (collector `query-stats`, kan ændres med `SQLInsight:CollectorIntervals`) og gemmer, hvad hver forespørgsel brugte siden sidst:
- Forespørgsler med samme struktur men forskellige værdier tælles som én (`query_hash`). På SQL Server 2005 er en forespørgsel en sætning i en batch.
- Kun de forespørgsler, der har kørt siden sidst, og blandt dem de 50 tungeste efter CPU, tid, læsninger og antal kørsler. Teksten gemmes én gang, højst 4.000 tegn. Ingen planer.
- En plan, der er kompileret om eller smidt ud af plan cache, starter forfra. Der tælles aldrig negativt. Efter en genstart af SQL Server går der én måling, før der er tal igen.
- Tal er kun så præcise som plan cache: forespørgsler med `OPTION (RECOMPILE)` eller planer, der smides ud mellem to målinger, kan mangle.
Rettigheden er `VIEW SERVER STATE` fra `grant-least-privilege.sql`. SQL Server 2000 har ikke DMV'en, så her er der ingen forespørgselsstatistik.
**Forespørgselstekst er kundedata.** Den gemmes kun i kundens eget repository, vises kun i webbrugerfladen og i PDF'en hos kunden, og sendes aldrig til SQLInsight Hub. Se [databehandling.md](databehandling.md).

View file

@ -97,4 +97,4 @@ Deling er slået fra som standard og er ikke en betingelse for Professional elle
## Kundens egen installation ## Kundens egen installation
Agent og webbrugerflade gemmer data i kundens eget repository hos kunden. Her er kunden selv ansvarlig. Repositoriet indeholder de samme typer data som ovenfor, inklusive følsomme målinger (forespørgselstekst, logins, deadlock-grafer), fordi de er nødvendige for at løse problemerne lokalt. Adgang til webbrugerfladen styres med Windows-login, og databasebrugeren har kun læserettigheder. Agent og webbrugerflade gemmer data i kundens eget repository hos kunden. Her er kunden selv ansvarlig. Repositoriet indeholder de samme typer data som ovenfor, inklusive følsomme målinger (forespørgselstekst, logins, deadlock-grafer), fordi de er nødvendige for at løse problemerne lokalt. Til [analyse af tidsrum](analyse.md) gemmes desuden de tungeste forespørgsler med tekst og forbrug hvert 5. minut i `RawDays` (standard 14 dage); den tekst bruges kun lokalt og indgår aldrig i det, der sendes til SQLInsight Hub. Adgang til webbrugerfladen styres med Windows-login, og databasebrugeren har kun læserettigheder.

View file

@ -1,5 +1,7 @@
# Rapporter # Rapporter
Hvad der skete i et bestemt tidsrum på én instans (Statspack-lignende, også som PDF) står i [analyse.md](analyse.md).
SQLInsight Pro laver en **driftsrapport** som PDF til ledelse, it-chef og revision. Den er skrevet til læsere, der ikke er DBA'er: 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. - **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.

View file

@ -22,6 +22,7 @@ public sealed class DocsService
("opdatering", "Regelpakker, nye versioner, stable og preview, installation uden internetadgang."), ("opdatering", "Regelpakker, nye versioner, stable og preview, installation uden internetadgang."),
("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."), ("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."),
("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."), ("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."),
("analyse", "Hvad skete der i et bestemt tidsrum: belastning, ventetider mod normalen, tungeste forespørgsler, blocking og deadlocks."),
("regler", "Skriv egne regler i SQL, og bidrag med community-regler."), ("regler", "Skriv egne regler i SQL, og bidrag med community-regler."),
("support", "Opret supportsager hos SEOSoft fra webbrugerfladen og følg deres status."), ("support", "Opret supportsager hos SEOSoft fra webbrugerfladen og følg deres status."),
("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."), ("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."),

View file

@ -0,0 +1,165 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
/// <summary>Which stored data a time window is read from.</summary>
public enum WindowResolution
{
/// <summary>5-minute samples, kept Retention:RawDays.</summary>
Raw,
/// <summary>Hourly averages and sums, kept Retention:HourlyDays. File I/O, top SQL and the like are not kept hourly.</summary>
Hourly,
}
/// <summary>
/// The time window: parsing what the user typed (in the display time zone) to UTC, presets, and which data to use.
/// </summary>
public static class TimeWindow
{
public const string InputFormat = "yyyy-MM-dd'T'HH:mm";
public static readonly TimeSpan MaxLength = TimeSpan.FromDays(31);
/// <summary>Raw samples when the whole window is still within their retention, otherwise the hourly history.</summary>
public static WindowResolution ChooseResolution(DateTimeOffset from, DateTimeOffset now, int rawDays) =>
from >= now.AddDays(-rawDays) ? WindowResolution.Raw : WindowResolution.Hourly;
/// <summary>Local wall-clock times from datetime-local fields (yyyy-MM-ddTHH:mm) to UTC.</summary>
public static bool TryParse(string? from, string? to, TimeZoneInfo zone, out DateTimeOffset fromUtc, out DateTimeOffset toUtc, out string? error)
{
ArgumentNullException.ThrowIfNull(zone);
fromUtc = toUtc = default;
if (!TryLocal(from, zone, out fromUtc) || !TryLocal(to, zone, out toUtc))
{
error = "Angiv fra og til som dato og klokkeslæt.";
return false;
}
error = toUtc <= fromUtc ? "Til skal ligge efter fra."
: toUtc - fromUtc > MaxLength ? $"Højst {MaxLength.TotalDays:0} dage ad gangen."
: null;
return error is null;
}
/// <summary>A UTC time as the value of a datetime-local field in the display time zone.</summary>
public static string ToInput(DateTimeOffset utc, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
return TimeZoneInfo.ConvertTime(utc, zone).ToString(InputFormat, CultureInfo.InvariantCulture);
}
/// <summary>
/// The quick choices: "seneste-time", "i-nat" (00–06 today, or until now if it is not yet 06) and "i-gaar".
/// Null for an unknown name.
/// </summary>
public static (DateTimeOffset From, DateTimeOffset To)? Preset(string? name, DateTimeOffset now, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
var local = TimeZoneInfo.ConvertTime(now, zone);
var midnight = local.Date;
return name switch
{
"seneste-time" => (now.AddHours(-1), now),
"i-nat" => (Utc(midnight, zone), local.Hour < 6 ? now : Utc(midnight.AddHours(6), zone)),
"i-gaar" => (Utc(midnight.AddDays(-1), zone), Utc(midnight, zone)),
_ => null,
};
}
/// <summary>The window around an event (blocking, deadlock): half an hour on each side.</summary>
public static (DateTimeOffset From, DateTimeOffset To) Around(DateTime utc) =>
(new DateTimeOffset(DateTime.SpecifyKind(utc, DateTimeKind.Utc)).AddMinutes(-30), new DateTimeOffset(DateTime.SpecifyKind(utc, DateTimeKind.Utc)).AddMinutes(30));
private static bool TryLocal(string? value, TimeZoneInfo zone, out DateTimeOffset utc)
{
utc = default;
if (!DateTime.TryParseExact(value, [InputFormat, "yyyy-MM-dd'T'HH:mm:ss"], CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
{
return false;
}
// A time skipped by the change to summer time does not exist; take the hour after.
if (zone.IsInvalidTime(local))
{
local = local.AddHours(1);
}
utc = Utc(local, zone);
return true;
}
private static DateTimeOffset Utc(DateTime local, TimeZoneInfo zone) =>
new(TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), zone), TimeSpan.Zero);
}
/// <summary>The top-SQL order as it appears in the URL (?sortering=…).</summary>
public static class AnalysisOrder
{
public static TopQueryOrder Parse(string? value) => value switch
{
"varighed" => TopQueryOrder.Duration,
"laesninger" => TopQueryOrder.Reads,
"koersler" => TopQueryOrder.Executions,
_ => TopQueryOrder.Cpu,
};
}
/// <summary>A wait type in the window compared with the same hours of the week in the previous weeks.</summary>
public sealed record WaitComparison(string WaitType, WaitCategory Category, long WaitTimeMs, long WaitingTasks, long SignalWaitTimeMs, double PerMinute, double? NormalPerMinute, double Share)
{
/// <summary>How many times the normal level; null without history.</summary>
public double? Ratio => NormalPerMinute is > 0 ? PerMinute / NormalPerMinute : null;
/// <summary>At least twice the normal and at least 5 % of the window's waits, or a significant wait never seen at that time before.</summary>
public bool IsUnusual => Share >= 0.05 && (Ratio >= 2 || NormalPerMinute is 0);
}
/// <summary>A metric in the window compared with the same hours in the previous weeks.</summary>
public sealed record MetricComparison(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max, IReadOnlyList<MetricPoint> Points, double? NormalAverage)
{
public double? Average => Points.Count == 0 ? null : Points.Average(p => p.Value);
public double? Peak => Points.Count == 0 ? null : Points.Max(p => p.Value);
/// <summary>50 % worse than normal: higher for load, lower for page life expectancy.</summary>
public bool IsUnusual => Average is { } avg && NormalAverage is > 0 and var normal
&& (LowerIsWorse ? avg <= normal * 0.5 : avg >= normal * 1.5 && avg - normal >= 1);
}
public static class WindowBaseline
{
/// <summary>Weeks back compared with: the same weekday and hours.</summary>
public const int Weeks = 4;
/// <summary>
/// Waits per minute in the window against the average per minute in the same hours of earlier weeks. Weeks without
/// any hourly data are left out; with no such week at all the normal is unknown (null).
/// </summary>
public static IReadOnlyList<WaitComparison> CompareWaits(IReadOnlyList<WaitTotal> window, double windowMinutes, IReadOnlyList<IReadOnlyList<HourlyWaitTotal>> earlierWeeks)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(earlierWeeks);
var weeks = earlierWeeks.Where(w => w.Count > 0 && w[0].Hours > 0).ToList();
var total = Math.Max(1, window.Sum(w => w.WaitTimeMs));
var minutes = Math.Max(1, windowMinutes);
return window
.OrderByDescending(w => w.WaitTimeMs)
.Select(w =>
{
double? normal = weeks.Count == 0
? null
: weeks.Average(week => (week.FirstOrDefault(x => x.WaitType == w.WaitType)?.WaitTimeMs ?? 0) / (week[0].Hours * 60.0));
return new WaitComparison(w.WaitType, w.Category, w.WaitTimeMs, w.WaitingTasks, w.SignalWaitTimeMs, w.WaitTimeMs / minutes, normal, (double)w.WaitTimeMs / total);
})
.ToList();
}
/// <summary>The average of the earlier weeks' hourly averages; null without any.</summary>
public static double? NormalAverage(IEnumerable<IReadOnlyList<MetricPoint>> earlierWeeks)
{
ArgumentNullException.ThrowIfNull(earlierWeeks);
var values = earlierWeeks.SelectMany(w => w).Select(p => p.Value).ToList();
return values.Count == 0 ? null : values.Average();
}
}

View file

@ -0,0 +1,105 @@
using SQLInsight.Repository;
namespace SQLInsight.Reports;
/// <summary>
/// Everything the repository holds about one instance in one time window, compared with the same hours of the
/// previous weeks: the Statspack-style "what happened between 03 and 04". Used by the web page and the PDF.
/// </summary>
public sealed record TimeWindowAnalysis
{
public required InstanceOverview Instance { get; init; }
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public required WindowResolution Resolution { get; init; }
public required int RawDays { get; init; }
public required IReadOnlyList<MetricComparison> Metrics { get; init; }
public required IReadOnlyList<WaitComparison> Waits { get; init; }
public required IReadOnlyList<FileLatency> Files { get; init; }
public required IReadOnlyList<StoredBlocking> Blocking { get; init; }
public required IReadOnlyList<StoredDeadlock> Deadlocks { get; init; }
public required IReadOnlyList<StoredFinding> Findings { get; init; }
public required TopQueryOrder QueryOrder { get; init; }
/// <summary>The heaviest statements. Their text is customer data: only for the local UI and PDF.</summary>
public required IReadOnlyList<TopQuery> TopQueries { get; init; }
public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Blocking.Count > 0
|| Deadlocks.Count > 0 || TopQueries.Count > 0;
/// <summary>The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum.</summary>
public static readonly IReadOnlyList<(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max)> MetricDefinitions =
[
(Repository.Metrics.CpuSqlServer, "SQL Server CPU", " %", 0, false, 100),
(Repository.Metrics.CpuOther, "CPU brugt af andre processer", " %", 0, false, 100),
(Repository.Metrics.BatchRequestsPerSecond, "Batch requests pr. sekund", "", 1, false, null),
(Repository.Metrics.CompilationsPerSecond, "SQL compilations pr. sekund", "", 1, false, null),
(Repository.Metrics.PageLifeExpectancy, "Page life expectancy (s)", "", 0, true, null),
(Repository.Metrics.UserConnections, "Brugerforbindelser", "", 0, false, null),
(Repository.Metrics.MemoryGrantsPending, "Memory grants pending", "", 0, false, null),
(Repository.Metrics.ProcessesBlocked, "Blokerede processer", "", 0, false, null),
];
public static async Task<TimeWindowAnalysis> LoadAsync(
InsightRepository repository, InstanceOverview instance, DateTimeOffset from, DateTimeOffset to, DateTimeOffset now,
int rawDays = 14, TopQueryOrder order = TopQueryOrder.Cpu, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(instance);
var id = instance.InstanceId;
var resolution = TimeWindow.ChooseResolution(from, now, rawDays);
var weeks = Enumerable.Range(1, WindowBaseline.Weeks).Select(w => TimeSpan.FromDays(7 * w)).ToList();
var metrics = new List<MetricComparison>();
foreach (var (metric, label, unit, decimals, lowerIsWorse, max) in MetricDefinitions)
{
var points = resolution == WindowResolution.Raw
? await repository.GetMetricSamplesAsync(id, metric, from, to, cancellationToken).ConfigureAwait(false)
: await repository.GetMetricHourlyAsync(id, metric, from, to, cancellationToken).ConfigureAwait(false);
var earlier = new List<IReadOnlyList<MetricPoint>>();
foreach (var back in weeks)
{
earlier.Add(await repository.GetMetricHourlyAsync(id, metric, from - back, to - back, cancellationToken).ConfigureAwait(false));
}
metrics.Add(new MetricComparison(metric, label, unit, decimals, lowerIsWorse, max, points, WindowBaseline.NormalAverage(earlier)));
}
IReadOnlyList<WaitTotal> windowWaits;
double minutes;
if (resolution == WindowResolution.Raw)
{
windowWaits = await repository.GetWaitsAsync(id, from, to, cancellationToken).ConfigureAwait(false);
minutes = (to - from).TotalMinutes;
}
else
{
var hourly = await repository.GetWaitsHourlyAsync(id, from, to, cancellationToken).ConfigureAwait(false);
windowWaits = hourly.Select(h => new WaitTotal(h.WaitType, h.WaitingTasks, h.WaitTimeMs, 0)).ToList();
minutes = hourly.Count == 0 ? (to - from).TotalMinutes : hourly[0].Hours * 60.0;
}
var earlierWaits = new List<IReadOnlyList<HourlyWaitTotal>>();
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),
};
}
}

View file

@ -0,0 +1,291 @@
using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Why a window has no data; shared with the web page.</summary>
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();
}
}

View file

@ -0,0 +1,252 @@
@page "/instanser/{Name}/analyse"
@using SQLInsight.Reports
@inject InsightRepository Repository
@inject Display Display
@if (_instance is null)
{
<PageTitle>Ukendt instans · SQLInsight Pro</PageTitle>
<h1>Ukendt instans</h1>
<p>Der findes ingen instans med navnet <strong>@Name</strong>.</p>
return;
}
<PageTitle>Analyse af @_instance.Name · SQLInsight Pro</PageTitle>
<p class="crumbs"><a href="/">Instanser</a> / <a href="@InstanceUrl">@_instance.Name</a> / Analyse af tidsrum</p>
<h1>Analyse af tidsrum</h1>
<p class="lead">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.</p>
<form method="get" class="panel window-form">
<label>Fra <input type="datetime-local" name="fra" value="@TimeWindow.ToInput(_from, Display.Zone)" required /></label>
<label>Til <input type="datetime-local" name="til" value="@TimeWindow.ToInput(_to, Display.Zone)" required /></label>
<label>Forespørgsler efter
<select name="sortering">
@foreach (var (value, label) in Orders)
{
<option value="@value" selected="@(value == Sortering)">@label</option>
}
</select>
</label>
<button type="submit" class="button">Vis</button>
<span class="presets">
<a href="@PresetUrl("seneste-time")">Seneste time</a>
<a href="@PresetUrl("i-nat")">I nat 00–06</a>
<a href="@PresetUrl("i-gaar")">I går</a>
</span>
</form>
@if (_error is not null)
{
<div class="alert">@_error</div>
return;
}
<p class="muted">@Display.Time(_from.UtcDateTime) – @Display.Time(_to.UtcDateTime) · @TimeWindowReport.ResolutionText(_analysis!)
· <a href="@PdfUrl">Hent som PDF</a></p>
@if (!_analysis!.HasData)
{
<div class="alert warn">@TimeWindowReport.NoDataText(_analysis)</div>
return;
}
<h2 class="section">Belastning</h2>
<div class="grid2">
@foreach (var m in _analysis.Metrics.Where(m => m.Points.Count > 0))
{
<div class="panel @(m.IsUnusual ? "unusual" : "")">
<Sparkline Points="m.Points" Label="@m.Label" Unit="@m.Unit" Max="m.Max" Decimals="m.Decimals" />
<p class="muted small">Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")</p>
</div>
}
</div>
<section class="card section">
<div class="card-head"><h2>Største ventetider</h2><span class="muted">pr. minut i tidsrummet mod normalen; uden harmløse baggrundsventetider</span></div>
@if (_analysis.Waits.Count == 0)
{
<p class="muted">Ingen ventetider målt i tidsrummet.</p>
}
else
{
<table>
<thead><tr><th>Wait type</th><th>Kategori</th><th class="num">Ventetid</th><th class="num">Pr. minut</th><th class="num">Normalt pr. minut</th><th class="num">Andel</th></tr></thead>
<tbody>
@foreach (var w in _analysis.Waits.Take(15))
{
<tr class="@(w.IsUnusual ? "unusual" : "")">
<td class="mono">@w.WaitType</td>
<td>@w.Category</td>
<td class="num">@Display.Number(w.WaitTimeMs / 1000.0, 1) s</td>
<td class="num">@Display.Number(w.PerMinute / 1000.0, 2) s</td>
<td class="num">@(w.NormalPerMinute is { } n ? Display.Number(n / 1000.0, 2) + " s" : "–")@(w.Ratio is { } r && w.IsUnusual ? $" (×{Display.Number(r, 1)})" : "")</td>
<td class="num">@Display.Number(100 * w.Share) %</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>Tungeste forespørgsler</h2><span class="muted">summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub</span></div>
@if (_analysis.TopQueries.Count == 0)
{
<p class="muted">Ingen forespørgselsstatistik i tidsrummet. Den indsamles hvert 5. minut fra version 1.0.3 og gemmes i @_analysis.RawDays dage.</p>
}
else
{
<table class="queries">
<thead><tr><th>Forespørgsel</th><th class="num">CPU</th><th class="num">Varighed</th><th class="num">Læsninger</th><th class="num">Kørsler</th></tr></thead>
<tbody>
@foreach (var q in _analysis.TopQueries)
{
<tr>
<td><span class="muted small">@q.DatabaseName</span><pre class="sql">@(q.Text ?? "(teksten er ikke længere i plan cache)")</pre></td>
<td class="num nowrap">@Display.Number(q.WorkerTimeUs / 1_000_000.0, 1) s</td>
<td class="num nowrap">@Display.Number(q.ElapsedTimeUs / 1_000_000.0, 1) s</td>
<td class="num">@Display.Number(q.LogicalReads)</td>
<td class="num">@Display.Number(q.Executions)</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>I/O-latens pr. fil</h2></div>
@if (_analysis.Files.Count == 0)
{
<p class="muted">Ingen I/O-målinger i tidsrummet.</p>
}
else
{
<table>
<thead><tr><th>Database</th><th>Fil</th><th>Type</th><th class="num">Læsninger</th><th class="num">Gns. læs</th><th class="num">Skrivninger</th><th class="num">Gns. skriv</th></tr></thead>
<tbody>
@foreach (var f in _analysis.Files.Take(25))
{
<tr>
<td>@f.DatabaseName</td>
<td>@f.FileId</td>
<td>@f.FileType</td>
<td class="num">@Display.Number(f.Reads)</td>
<td class="num @(f.AverageReadMs >= 20 ? "bad" : "")">@Ms(f.AverageReadMs)</td>
<td class="num">@Display.Number(f.Writes)</td>
<td class="num @(f.AverageWriteMs >= (f.FileType == "LOG" ? 5 : 20) ? "bad" : "")">@Ms(f.AverageWriteMs)</td>
</tr>
}
</tbody>
</table>
}
</section>
<div class="grid2">
<section class="card">
<div class="card-head"><h2>Blocking</h2></div>
@if (_analysis.Blocking.Count == 0)
{
<p class="muted">Ingen blocking over 5 sekunder i tidsrummet.</p>
}
else
{
<table>
<thead><tr><th>Tidspunkt</th><th>Head blocker</th><th class="num">Blokerede</th><th class="num">Længste</th></tr></thead>
<tbody>
@foreach (var b in _analysis.Blocking)
{
var head = b.Sessions.Count > 0 ? b.Sessions[0] : null;
<tr>
<td>@Display.Time(b.SampledAt)</td>
<td>@b.HeadSessionId <span class="muted">@head?.Status @head?.ProgramName @head?.LoginName</span></td>
<td class="num">@b.BlockedCount</td>
<td class="num">@Display.Number(b.MaxWaitMs / 1000.0) s</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>Deadlocks</h2></div>
@if (_analysis.Deadlocks.Count == 0)
{
<p class="muted">Ingen deadlocks i tidsrummet.</p>
}
else
{
<table>
<thead><tr><th>Tidspunkt</th><th>Offer</th><th>Graf</th></tr></thead>
<tbody>
@foreach (var d in _analysis.Deadlocks)
{
var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
<tr>
<td>@Display.Time(d.OccurredAt)</td>
<td>session @victim?.SessionId <span class="muted">@victim?.ClientApp</span></td>
<td><a href="/deadlocks/@(d.DeadlockId).xdl">.xdl</a></td>
</tr>
}
</tbody>
</table>
}
</section>
</div>
<section class="card section">
<div class="card-head"><h2>Fund, der var aktive i tidsrummet</h2><span class="muted">@_analysis.Findings.Count fund</span></div>
<FindingList Findings="_analysis.Findings" Empty="Ingen." />
</section>
@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" : "–";
}

View file

@ -36,7 +36,10 @@
<FindingList Findings="_findings" Empty="Ingen aktive fund." /> <FindingList Findings="_findings" Empty="Ingen aktive fund." />
</section> </section>
<h2 class="section">Belastning, seneste 24 timer</h2> <div class="section-head">
<h2 class="section">Belastning, seneste 24 timer</h2>
<a class="button" href="@AnalysisUrl(null)">Analysér et tidsrum</a>
</div>
<div class="grid2"> <div class="grid2">
<div class="panel"><Sparkline Points="_cpuSql" Label="SQL Server CPU" Unit=" %" Max="100" /></div> <div class="panel"><Sparkline Points="_cpuSql" Label="SQL Server CPU" Unit=" %" Max="100" /></div>
<div class="panel"><Sparkline Points="_cpuOther" Label="CPU brugt af andre processer" Unit=" %" Max="100" Empty="kan ikke måles på Linux, eller endnu ikke nok målinger." /></div> <div class="panel"><Sparkline Points="_cpuOther" Label="CPU brugt af andre processer" Unit=" %" Max="100" Empty="kan ikke måles på Linux, eller endnu ikke nok målinger." /></div>
@ -116,7 +119,7 @@ else
{ {
var head = b.Sessions.FirstOrDefault(); var head = b.Sessions.FirstOrDefault();
<tr> <tr>
<td>@Display.Time(b.SampledAt)</td> <td><a href="@AnalysisUrl(b.SampledAt)" title="Analysér tidsrummet omkring">@Display.Time(b.SampledAt)</a></td>
<td>@b.HeadSessionId <span class="muted">@head?.Status @head?.ProgramName</span></td> <td>@b.HeadSessionId <span class="muted">@head?.Status @head?.ProgramName</span></td>
<td class="num">@b.BlockedCount</td> <td class="num">@b.BlockedCount</td>
<td class="num">@Display.Number(b.MaxWaitMs / 1000.0) s</td> <td class="num">@Display.Number(b.MaxWaitMs / 1000.0) s</td>
@ -141,7 +144,7 @@ else
{ {
var victim = d.Processes.FirstOrDefault(p => p.IsVictim); var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
<tr> <tr>
<td>@Display.Time(d.OccurredAt)</td> <td><a href="@AnalysisUrl(d.OccurredAt)" title="Analysér tidsrummet omkring">@Display.Time(d.OccurredAt)</a></td>
<td>session @victim?.SessionId <span class="muted">@victim?.ClientApp</span></td> <td>session @victim?.SessionId <span class="muted">@victim?.ClientApp</span></td>
<td><a href="/deadlocks/@(d.DeadlockId).xdl">.xdl</a></td> <td><a href="/deadlocks/@(d.DeadlockId).xdl">.xdl</a></td>
</tr> </tr>
@ -231,5 +234,19 @@ else
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–"; private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
/// <summary>The time window analysis: the last hour, or half an hour on each side of an event.</summary>
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; private static string LatencyClass(double? value, double threshold) => value >= threshold ? "bad" : string.Empty;
} }

View file

@ -16,6 +16,9 @@ public sealed class Display(IOptions<WebOptions> options)
? TimeZoneInfo.FindSystemTimeZoneById(id) ? TimeZoneInfo.FindSystemTimeZoneById(id)
: TimeZoneInfo.Local; : TimeZoneInfo.Local;
/// <summary>The time zone times are shown in (SQLInsight:Web:TimeZone, or the server's).</summary>
public TimeZoneInfo Zone => _zone;
public string Time(DateTime? utc) => utc is { } value public string Time(DateTime? utc) => utc is { } value
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(value, DateTimeKind.Utc), _zone).ToString("dd-MM-yyyy HH:mm", Danish) ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(value, DateTimeKind.Utc), _zone).ToString("dd-MM-yyyy HH:mm", Danish)
: "–"; : "–";

View file

@ -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); 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"); 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<WebOptions> 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<App>(); app.MapRazorComponents<App>();
// The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader. // The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader.

View file

@ -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 { 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 { 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; } .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; }

View file

@ -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<WaitTotal>
{
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<IReadOnlyList<HourlyWaitTotal>> 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));
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
[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<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>(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<int>("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<Program>().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);
}
/// <summary>A busy hour: high CPU, lock waits and one heavy statement, against quiet same hours four weeks back.</summary>
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<QueryStatsSnapshot, QueryStatsDelta>(
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,
})],
}));
/// <summary>Like an application: connected to its own database, so the plan belongs to it.</summary>
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);
}