From 554a6a71f5878dd59dbccc2a44236ab480c32b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Thu, 1 Oct 2026 15:46:56 +0200 Subject: [PATCH] Time window analysis: what happened between 03 and 04, as a page and PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/analyse.md | 45 +++ docs/databehandling.md | 2 +- docs/rapporter.md | 2 + .../Services/DocsService.cs | 1 + src/SQLInsight.Reports/TimeWindow.cs | 165 ++++++++++ src/SQLInsight.Reports/TimeWindowAnalysis.cs | 105 +++++++ src/SQLInsight.Reports/TimeWindowReport.cs | 291 ++++++++++++++++++ .../Components/Pages/Analysis.razor | 252 +++++++++++++++ .../Components/Pages/Instance.razor | 23 +- src/SQLInsight.Web/Display.cs | 3 + src/SQLInsight.Web/Program.cs | 21 ++ src/SQLInsight.Web/wwwroot/app.css | 13 + .../SQLInsight.Agent.Tests/TimeWindowTests.cs | 133 ++++++++ .../TimeWindowAnalysisTests.cs | 218 +++++++++++++ 14 files changed, 1270 insertions(+), 4 deletions(-) create mode 100644 docs/analyse.md create mode 100644 src/SQLInsight.Reports/TimeWindow.cs create mode 100644 src/SQLInsight.Reports/TimeWindowAnalysis.cs create mode 100644 src/SQLInsight.Reports/TimeWindowReport.cs create mode 100644 src/SQLInsight.Web/Components/Pages/Analysis.razor create mode 100644 tests/SQLInsight.Agent.Tests/TimeWindowTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs diff --git a/docs/analyse.md b/docs/analyse.md new file mode 100644 index 0000000..1a71a4c --- /dev/null +++ b/docs/analyse.md @@ -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). diff --git a/docs/databehandling.md b/docs/databehandling.md index 62752a7..797384e 100644 --- a/docs/databehandling.md +++ b/docs/databehandling.md @@ -97,4 +97,4 @@ Deling er slået fra som standard og er ikke en betingelse for Professional elle ## 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. diff --git a/docs/rapporter.md b/docs/rapporter.md index bb99d6b..79886d2 100644 --- a/docs/rapporter.md +++ b/docs/rapporter.md @@ -1,5 +1,7 @@ # 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: - **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. diff --git a/src/SQLInsight.Community/Services/DocsService.cs b/src/SQLInsight.Community/Services/DocsService.cs index 704cfe8..f0e73f4 100644 --- a/src/SQLInsight.Community/Services/DocsService.cs +++ b/src/SQLInsight.Community/Services/DocsService.cs @@ -22,6 +22,7 @@ public sealed class DocsService ("opdatering", "Regelpakker, nye versioner, stable og preview, installation uden internetadgang."), ("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."), ("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."), ("support", "Opret supportsager hos SEOSoft fra webbrugerfladen og følg deres status."), ("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."), diff --git a/src/SQLInsight.Reports/TimeWindow.cs b/src/SQLInsight.Reports/TimeWindow.cs new file mode 100644 index 0000000..1ccda8f --- /dev/null +++ b/src/SQLInsight.Reports/TimeWindow.cs @@ -0,0 +1,165 @@ +using System.Globalization; +using SQLInsight.Core.Data; +using SQLInsight.Repository; + +namespace SQLInsight.Reports; + +/// Which stored data a time window is read from. +public enum WindowResolution +{ + /// 5-minute samples, kept Retention:RawDays. + Raw, + + /// Hourly averages and sums, kept Retention:HourlyDays. File I/O, top SQL and the like are not kept hourly. + Hourly, +} + +/// +/// The time window: parsing what the user typed (in the display time zone) to UTC, presets, and which data to use. +/// +public static class TimeWindow +{ + public const string InputFormat = "yyyy-MM-dd'T'HH:mm"; + public static readonly TimeSpan MaxLength = TimeSpan.FromDays(31); + + /// Raw samples when the whole window is still within their retention, otherwise the hourly history. + public static WindowResolution ChooseResolution(DateTimeOffset from, DateTimeOffset now, int rawDays) => + from >= now.AddDays(-rawDays) ? WindowResolution.Raw : WindowResolution.Hourly; + + /// Local wall-clock times from datetime-local fields (yyyy-MM-ddTHH:mm) to UTC. + 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; + } + + /// A UTC time as the value of a datetime-local field in the display time zone. + public static string ToInput(DateTimeOffset utc, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + return TimeZoneInfo.ConvertTime(utc, zone).ToString(InputFormat, CultureInfo.InvariantCulture); + } + + /// + /// 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. + /// + 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, + }; + } + + /// The window around an event (blocking, deadlock): half an hour on each side. + 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); +} + +/// The top-SQL order as it appears in the URL (?sortering=…). +public static class AnalysisOrder +{ + public static TopQueryOrder Parse(string? value) => value switch + { + "varighed" => TopQueryOrder.Duration, + "laesninger" => TopQueryOrder.Reads, + "koersler" => TopQueryOrder.Executions, + _ => TopQueryOrder.Cpu, + }; +} + +/// A wait type in the window compared with the same hours of the week in the previous weeks. +public sealed record WaitComparison(string WaitType, WaitCategory Category, long WaitTimeMs, long WaitingTasks, long SignalWaitTimeMs, double PerMinute, double? NormalPerMinute, double Share) +{ + /// How many times the normal level; null without history. + public double? Ratio => NormalPerMinute is > 0 ? PerMinute / NormalPerMinute : null; + + /// At least twice the normal and at least 5 % of the window's waits, or a significant wait never seen at that time before. + public bool IsUnusual => Share >= 0.05 && (Ratio >= 2 || NormalPerMinute is 0); +} + +/// A metric in the window compared with the same hours in the previous weeks. +public sealed record MetricComparison(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max, IReadOnlyList 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); + + /// 50 % worse than normal: higher for load, lower for page life expectancy. + 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 +{ + /// Weeks back compared with: the same weekday and hours. + public const int Weeks = 4; + + /// + /// 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). + /// + public static IReadOnlyList CompareWaits(IReadOnlyList window, double windowMinutes, IReadOnlyList> 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(); + } + + /// The average of the earlier weeks' hourly averages; null without any. + public static double? NormalAverage(IEnumerable> earlierWeeks) + { + ArgumentNullException.ThrowIfNull(earlierWeeks); + var values = earlierWeeks.SelectMany(w => w).Select(p => p.Value).ToList(); + return values.Count == 0 ? null : values.Average(); + } +} diff --git a/src/SQLInsight.Reports/TimeWindowAnalysis.cs b/src/SQLInsight.Reports/TimeWindowAnalysis.cs new file mode 100644 index 0000000..b39483d --- /dev/null +++ b/src/SQLInsight.Reports/TimeWindowAnalysis.cs @@ -0,0 +1,105 @@ +using SQLInsight.Repository; + +namespace SQLInsight.Reports; + +/// +/// 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. +/// +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 Metrics { get; init; } + public required IReadOnlyList Waits { get; init; } + public required IReadOnlyList Files { get; init; } + public required IReadOnlyList Blocking { get; init; } + public required IReadOnlyList Deadlocks { get; init; } + public required IReadOnlyList Findings { get; init; } + public required TopQueryOrder QueryOrder { get; init; } + + /// The heaviest statements. Their text is customer data: only for the local UI and PDF. + public required IReadOnlyList 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; + + /// The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum. + 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 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(); + 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>(); + 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 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>(); + 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.

+ +
+ + + + + + Seneste time + I nat 00–06 + I går + +
+ +@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 + { + + + + @foreach (var w in _analysis.Waits.Take(15)) + { + + + + + + + + + } + +
Wait typeKategoriVentetidPr. minutNormalt pr. minutAndel
@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 + { + + + + @foreach (var q in _analysis.TopQueries) + { + + + + + + + + } + +
ForespørgselCPUVarighedLæsningerKørsler
@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 + { + + + + @foreach (var f in _analysis.Files.Take(25)) + { + + + + + + + + + + } + +
DatabaseFilTypeLæsningerGns. læsSkrivningerGns. skriv
@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 + { + + + + @foreach (var b in _analysis.Blocking) + { + var head = b.Sessions.Count > 0 ? b.Sessions[0] : null; + + + + + + + } + +
TidspunktHead blockerBlokeredeLængste
@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 + { + + + + @foreach (var d in _analysis.Deadlocks) + { + var victim = d.Processes.FirstOrDefault(p => p.IsVictim); + + + + + + } + +
TidspunktOfferGraf
@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

+
+

Belastning, seneste 24 timer

+ Analysér et tidsrum +
@@ -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); +}