diff --git a/docs/analyse.md b/docs/analyse.md index d4aa5c3..ad19956 100644 --- a/docs/analyse.md +++ b/docs/analyse.md @@ -74,3 +74,22 @@ Ventetidsstatistikken (`sys.dm_os_wait_stats`) siger hvor meget der ventes, men - Ventetider, der varer under 15 sekunder i alt, kan falde mellem to stikprøver. Tallene er et billede af, hvor ventetiden ligger, ikke en præcis måling. - Virker fra SQL Server 2005 (her er nøglen batch og position, fordi `query_hash` først kom i 2008). Intervallet kan ændres med `SQLInsight:CollectorIntervals:wait-sampling`. - Sætningernes tekst er kundens data og sendes aldrig til SQLInsight Hub. + +## Hvad betyder målingerne? + +Under hver graf på instanssiden og i analysen står en vurdering i farve – **OK** (grøn), **Bemærk** (gul) eller **Reagér** (rød) – med et konkret næste skridt og et link, og en fold-ud "Hvad betyder det?" med en forklaring i almindeligt sprog. Samme vurdering står i PDF'en. Reglerne: + +| Måling | Bemærk | Reagér | +|---|---|---| +| CPU (SQL Server + andre) | Gns. over 60 % | Over 80 % i mindst 3 målinger i træk; peger på andre processer, hvis de bruger over 20 % eller mere end SQL Server | +| CPU brugt af andre processer | Gns. 10 % eller mere | Over 30 % vedvarende, eller gns. 20 % og mere end SQL Server | +| Page life expectancy | Laveste værdi under grænsen, et fald på over 50 % undervejs, eller max server memory ikke sat | Gennemsnit under grænsen: 300 s pr. 4 GB buffer pool (max server memory, ellers serverens hukommelse) | +| SQL compilations | Over 10 % af batch requests | Over 20 % af batch requests (kun ved mindst 5 batches/s) | +| Memory grants pending, blokerede processer | Over 0 kortvarigt | Over 0 i mindst 3 målinger i træk | +| Brugerforbindelser | Højeste over dobbelt gennemsnit | – | +| Batch requests | Altid OK: et mål for travlhed | – | +| Disk, læsning fra datafiler | Over 10 ms | Over 20 ms | +| Disk, skrivning til datafiler | Over 10 ms | Over 20 ms | +| Disk, skrivning til log (drev kun med logfiler) | Over 5 ms | Over 15 ms | + +Diske vurderes kun med mindst 100 læsninger/skrivninger i perioden. Logikken ligger ét sted (`MetricAssessment`), så siden og PDF'en siger det samme. diff --git a/src/SQLInsight.Reports/MetricAssessment.cs b/src/SQLInsight.Reports/MetricAssessment.cs new file mode 100644 index 0000000..521b748 --- /dev/null +++ b/src/SQLInsight.Reports/MetricAssessment.cs @@ -0,0 +1,330 @@ +using System.Globalization; +using SQLInsight.Core.Data; +using SQLInsight.Repository; + +namespace SQLInsight.Reports; + +public enum AssessmentLevel +{ + /// Not enough data to say. + Unknown, + Ok, + + /// "Bemærk": worth knowing, not urgent. + Note, + + /// "Reagér": act on it. + Act, +} + +/// What the numbers say, in one or two sentences. +/// The concrete next action; null when there is nothing to do. +/// A page in SQLInsight Pro (relative to the instance page) or documentation for the next step. +public sealed record Assessment(AssessmentLevel Level, string Summary, string? NextStep = null, string? Link = null) +{ + public string LevelText => MetricAssessment.LevelText(Level); +} + +/// What is known about the instance besides the measurement itself. +/// The memory SQL Server may use for data: max server memory, or else the server's memory. +/// False when max server memory is SQL Server's default (unlimited). +public sealed record MetricContext( + double? BufferPoolGb = null, + bool? MaxServerMemorySet = null, + IReadOnlyList? BatchRequests = null, + IReadOnlyList? CpuSql = null, + IReadOnlyList? CpuOther = null) +{ + public static MetricContext From(ConfigurationSnapshot? configuration, ServerSnapshot? server, + IReadOnlyList? batchRequests = null, IReadOnlyList? cpuSql = null, IReadOnlyList? cpuOther = null) + { + var maxMb = configuration?.Find("max server memory (MB)")?.ValueInUse; + bool? set = maxMb is null ? null : maxMb < 2147483647; + double? poolMb = set == true ? maxMb : server?.PhysicalMemoryMb is > 0 and var physical ? physical : null; + if (set == true && server?.PhysicalMemoryMb is > 0 and var total) + { + poolMb = Math.Min(poolMb!.Value, total); + } + + return new MetricContext(poolMb / 1024.0, set, batchRequests, cpuSql, cpuOther); + } +} + +/// +/// What a measurement means for the customer and how it looks: OK, "Bemærk" or "Reagér", with a concrete next step. +/// Shared by the instance page, the time window analysis and the PDFs, so they all say the same. +/// +public static class MetricAssessment +{ + /// The classic rule of thumb, scaled: 300 seconds per 4 GB of buffer pool. + public const double PleSecondsPer4Gb = 300; + + /// A count above 0 in this many consecutive samples is sustained, not a blip. + public const int Sustained = 3; + + private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK"); + + public static string LevelText(AssessmentLevel level) => level switch + { + AssessmentLevel.Ok => "OK", + AssessmentLevel.Note => "Bemærk", + AssessmentLevel.Act => "Reagér", + _ => "Ukendt", + }; + + /// "Hvad betyder det?" for a metric, in plain Danish. + public static string Explanation(string metric) => metric switch + { + Metrics.CpuSqlServer => + "Hvor meget af serverens samlede CPU SQL Server bruger. Høj CPU er ikke i sig selv et problem, men vedvarende over 80 % " + + "betyder, at forespørgsler må vente på at komme til, og at der ikke er luft til spidsbelastninger.", + Metrics.CpuOther => + "CPU brugt af andre programmer på samme server (antivirus, backup-agenter, applikationer, AOS). Den CPU mangler SQL Server. " + + "På en dedikeret databaseserver bør den være lav.", + Metrics.BatchRequestsPerSecond => + "Hvor mange forespørgsler (batches) applikationerne sender pr. sekund. Det er et mål for, hvor travlt serveren har det, " + + "ikke for et problem i sig selv. Brug det til at se, om en langsom periode skyldes mere arbejde end normalt.", + Metrics.CompilationsPerSecond => + "Hvor mange gange pr. sekund SQL Server skal lave en ny forespørgselsplan. Planer genbruges normalt; kompilering koster CPU. " + + "Sammenhold med batch requests: over ca. 10–20 % af batches tyder på forespørgsler, der ikke genbruger planer (literaler i stedet for parametre).", + Metrics.PageLifeExpectancy => + "Hvor længe (sekunder) en dataside i gennemsnit bliver i hukommelsen, før den skal læses fra disk igen. Højt er godt. " + + "Tommelfingerreglen er mindst 300 sekunder pr. 4 GB buffer pool. Pludselige fald betyder, at en forespørgsel har læst store mængder data og skubbet andet ud.", + Metrics.UserConnections => + "Antal forbindelser fra brugere og applikationer. Et mål for brugen; et pludseligt hop kan betyde, at forbindelser ikke lukkes (connection leak), eller at sessioner hænger og venter.", + Metrics.MemoryGrantsPending => + "Forespørgsler, der venter på at få hukommelse til sortering og hash, før de overhovedet kan starte. Bør altid være 0; " + + "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).", + Metrics.ProcessesBlocked => + "Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.", + "disk.read" => + "Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.", + "disk.write.log" => + "Gennemsnitlig tid pr. skrivning til transaktionsloggen. Hver commit venter på den, så den skal være lav: under 5 ms, helst 1–2 ms.", + "disk.write" => + "Gennemsnitlig tid pr. skrivning til datafilerne. Skrivninger til datafiler sker mest i baggrunden (checkpoint), så de tåler mere end loggen; over 20 ms er højt.", + _ => "Ingen forklaring.", + }; + + /// The assessment of a metric's samples in a period. + public static Assessment Assess(string metric, IReadOnlyList points, MetricContext? context = null) + { + ArgumentNullException.ThrowIfNull(points); + context ??= new MetricContext(); + if (points.Count == 0) + { + return new(AssessmentLevel.Unknown, "Ingen målinger i perioden."); + } + + return metric switch + { + Metrics.CpuSqlServer => Cpu(points, context.CpuOther), + Metrics.CpuOther => CpuOther(points, context.CpuSql), + Metrics.PageLifeExpectancy => Ple(points, context), + Metrics.CompilationsPerSecond => Compilations(points, context.BatchRequests), + Metrics.MemoryGrantsPending => SustainedCount(points, + "Ingen forespørgsler har ventet på hukommelse.", + "forespørgsler ventede på hukommelse", + "Find forespørgslerne med store memory grants (ventetypen RESOURCE_SEMAPHORE), opdatér statistikker og tilføj indexes, så de sorterer mindre; tjek max server memory.", + "ventetyper/RESOURCE_SEMAPHORE"), + Metrics.ProcessesBlocked => SustainedCount(points, + "Ingen blokerede processer.", + "sessioner var blokeret", + "Se blocking-kæderne for, hvem der blokerer (head blocker), og hvilken sætning den kører; hold transaktioner korte.", + "analyse#blocking"), + Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok, + $"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."), + Metrics.UserConnections => Connections(points), + _ => new(AssessmentLevel.Unknown, "Ingen vurdering."), + }; + } + + /// Disk latency on a drive: data reads, data writes, log writes. + public static Assessment AssessDisk(DiskActivity disk) + { + ArgumentNullException.ThrowIfNull(disk); + var worst = AssessmentLevel.Unknown; + var parts = new List(); + if (disk.AverageReadMs is { } read && disk.Reads >= 100) + { + var level = read > 20 ? AssessmentLevel.Act : read > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok; + worst = Max(worst, level); + parts.Add($"læsning {N(read, 1)} ms"); + } + + if (disk.AverageWriteMs is { } write && disk.Writes >= 100) + { + var logOnly = disk.DataFiles == 0; + var level = logOnly + ? write > 15 ? AssessmentLevel.Act : write > 5 ? AssessmentLevel.Note : AssessmentLevel.Ok + : write > 20 ? AssessmentLevel.Act : write > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok; + worst = Max(worst, level); + parts.Add($"skrivning {N(write, 1)} ms{(logOnly ? " (log)" : "")}"); + } + + if (worst == AssessmentLevel.Unknown) + { + return new(AssessmentLevel.Unknown, "For lidt I/O i perioden til at vurdere."); + } + + var summary = $"{disk.Volume}: {string.Join(", ", parts)}."; + return worst switch + { + AssessmentLevel.Act => new(worst, summary + " Brugerne mærker det.", + "Tjek om disken er overbelastet (mange forespørgsler, der læser meget) eller langsom (storage, SAN, virtuel disk). Find de forespørgsler, der læser mest, før der købes hurtigere disk.", + "analyse"), + AssessmentLevel.Note => new(worst, summary + " Lidt højt.", "Hold øje med, om det følger bestemte tidspunkter (backup, indexvedligehold, batchjob).", "analyse"), + _ => new(worst, summary + " Godt."), + }; + } + + private static Assessment Cpu(IReadOnlyList sql, IReadOnlyList? other) + { + IReadOnlyList total = other is { Count: > 0 } ? Combine(sql, other) : sql; + var avg = Average(total); + var otherAvg = other is { Count: > 0 } ? Average(other) : 0; + var highRun = LongestRun(total, v => v > 80); + var summary = $"I gennemsnit {N(avg, 0)} % samlet CPU, højst {N(Peak(total), 0)} %" + + (other is { Count: > 0 } ? $"; heraf {N(Average(sql), 0)} % SQL Server og {N(otherAvg, 0)} % andre processer." : "."); + if (highRun >= Sustained) + { + return otherAvg >= 20 || otherAvg > Average(sql) + ? new(AssessmentLevel.Act, summary + " CPU'en er vedvarende over 80 %, og en stor del bruges af andre processer.", + "Find hvilke programmer på serveren der bruger CPU'en (Jobliste/Ressourceovervågning): antivirus uden undtagelser for databasefiler, backup-agenter eller en AOS på samme server.") + : new(AssessmentLevel.Act, summary + " SQL Server bruger vedvarende over 80 % af CPU'en.", + "Find forespørgslerne med højest CPU i analysen af tidsrummet og optimér dem (indexes, implicitte konverteringer); tjek MAXDOP og cost threshold for parallelism.", + "analyse?sortering=cpu"); + } + + return avg > 60 + ? new(AssessmentLevel.Note, summary + " Højt, men ikke vedvarende over 80 %.", "Hold øje med spidsbelastninger, og se hvilke forespørgsler der bruger CPU'en.", "analyse?sortering=cpu") + : new(AssessmentLevel.Ok, summary); + } + + private static Assessment CpuOther(IReadOnlyList other, IReadOnlyList? sql) + { + var avg = Average(other); + var summary = $"Andre processer brugte i gennemsnit {N(avg, 0)} % af CPU'en, højst {N(Peak(other), 0)} %."; + if (LongestRun(other, v => v > 30) >= Sustained || avg >= 20 && (sql is not { Count: > 0 } || avg > Average(sql))) + { + return new(AssessmentLevel.Act, summary + " Det er meget for en databaseserver.", + "Find programmet på serveren (Jobliste/Ressourceovervågning). Typisk antivirus uden undtagelser for .mdf/.ldf, backup-software eller en applikation på samme maskine."); + } + + return avg >= 10 + ? new(AssessmentLevel.Note, summary, "Undersøg hvilke programmer der kører på serveren ud over SQL Server.") + : new(AssessmentLevel.Ok, summary); + } + + private static Assessment Ple(IReadOnlyList points, MetricContext context) + { + var threshold = Math.Max(PleSecondsPer4Gb, PleSecondsPer4Gb * (context.BufferPoolGb ?? 4) / 4); + var avg = Average(points); + var min = points.Min(p => p.Value); + var median = Median(points); + var dip = median > 0 && min < median * 0.5; + var memory = context.MaxServerMemorySet == false + ? " max server memory er ikke sat, så SQL Server kan tage hukommelse fra Windows og andre programmer; sæt den (se konfigurationen)." + : ""; + var summary = $"I gennemsnit {N(avg, 0)} s, laveste {N(min, 0)} s. Grænsen er {N(threshold, 0)} s" + + (context.BufferPoolGb is { } gb ? $" ({N(PleSecondsPer4Gb, 0)} s pr. 4 GB af {N(gb, 0)} GB buffer pool)." : " (300 s pr. 4 GB buffer pool)."); + if (avg < threshold) + { + return new(AssessmentLevel.Act, summary + " Data bliver ikke længe i hukommelsen, så der læses meget fra disk." + memory, + "Find forespørgslerne, der læser mest (analysen sorteret efter læsninger), og giv dem bedre indexes; overvej mere hukommelse, når de er effektive.", + "analyse?sortering=laesninger"); + } + + if (dip || min < threshold) + { + return new(AssessmentLevel.Note, summary + $" Faldt{(dip ? " med mere end 50 %" : "")} undervejs: en forespørgsel har læst store mængder data og skubbet andet ud af hukommelsen." + memory, + "Find tidspunktet for faldet i grafen og se, hvilke forespørgsler der læste mest i det tidsrum.", "analyse?sortering=laesninger"); + } + + return memory.Length > 0 + ? new(AssessmentLevel.Note, summary + memory, "Sæt max server memory.", "konfiguration") + : new(AssessmentLevel.Ok, summary + " Godt."); + } + + private static Assessment Compilations(IReadOnlyList compilations, IReadOnlyList? batches) + { + var avg = Average(compilations); + if (batches is not { Count: > 0 } || Average(batches) <= 0) + { + return new(AssessmentLevel.Unknown, $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund; batch requests mangler til sammenligning."); + } + + var ratio = avg / Average(batches); + var summary = $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund = {N(100 * ratio, 0)} % af batches."; + if (Average(batches) < 5) + { + return new(AssessmentLevel.Ok, summary + " Så lidt arbejde, at andelen ikke betyder noget."); + } + + return ratio switch + { + > 0.2 => new(AssessmentLevel.Act, summary + " Mange forespørgsler får en ny plan hver gang, hvilket koster CPU.", + "Find ad hoc-forespørgsler med literaler i plan cache; brug parametre, slå 'optimize for ad hoc workloads' til, og overvej forced parameterization for databasen. I AX: undgå 'Use literals'.", + "ventetyper/RESOURCE_SEMAPHORE_QUERY_COMPILE"), + > 0.1 => new(AssessmentLevel.Note, summary + " Lidt højt.", "Hold øje med CPU; se efter forespørgsler med literaler."), + _ => new(AssessmentLevel.Ok, summary + " Planer genbruges fint."), + }; + } + + private static Assessment SustainedCount(IReadOnlyList points, string none, string what, string action, string link) + { + var run = LongestRun(points, v => v > 0); + var peak = Peak(points); + if (run == 0) + { + return new(AssessmentLevel.Ok, none); + } + + var summary = $"{N(peak, 0)} {what} (højst); over 0 i {points.Count(p => p.Value > 0)} af {points.Count} målinger."; + return run >= Sustained + ? new(AssessmentLevel.Act, summary + " Det varede ved.", action, link) + : new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link); + } + + private static Assessment Connections(IReadOnlyList points) + { + var avg = Average(points); + var peak = Peak(points); + var summary = $"I gennemsnit {N(avg, 0)} forbindelser, højst {N(peak, 0)}."; + return avg >= 10 && peak > 2 * avg + ? new(AssessmentLevel.Note, summary + " Antallet steg kraftigt undervejs.", "Se om det skyldes flere brugere, eller sessioner der hænger (blocking) eller ikke lukkes.", "analyse#blocking") + : new(AssessmentLevel.Ok, summary); + } + + private static List Combine(IReadOnlyList a, IReadOnlyList b) + { + var other = b.GroupBy(p => p.At).ToDictionary(g => g.Key, g => g.First().Value); + return a.Select(p => p with { Value = p.Value + (other.TryGetValue(p.At, out var v) ? v : 0) }).ToList(); + } + + private static int LongestRun(IReadOnlyList points, Func predicate) + { + int best = 0, run = 0; + foreach (var p in points.OrderBy(p => p.At)) + { + run = predicate(p.Value) ? run + 1 : 0; + best = Math.Max(best, run); + } + + return best; + } + + private static double Average(IReadOnlyList points) => points.Count == 0 ? 0 : points.Average(p => p.Value); + + private static double Peak(IReadOnlyList points) => points.Count == 0 ? 0 : points.Max(p => p.Value); + + private static double Median(IReadOnlyList points) + { + var sorted = points.Select(p => p.Value).Order().ToList(); + return sorted.Count == 0 ? 0 : sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2; + } + + private static AssessmentLevel Max(AssessmentLevel a, AssessmentLevel b) => (AssessmentLevel)Math.Max((int)a, (int)b); + + private static string N(double value, int decimals) => value.ToString("N" + decimals, Danish); +} diff --git a/src/SQLInsight.Reports/TimeWindowAnalysis.cs b/src/SQLInsight.Reports/TimeWindowAnalysis.cs index 5cbba61..3af22c6 100644 --- a/src/SQLInsight.Reports/TimeWindowAnalysis.cs +++ b/src/SQLInsight.Reports/TimeWindowAnalysis.cs @@ -28,6 +28,19 @@ public sealed record TimeWindowAnalysis /// The heaviest statements. Their text is customer data: only for the local UI and PDF. public required IReadOnlyList TopQueries { get; init; } + /// What the assessment of the measurements needs besides them (buffer pool size, max server memory). + public MetricContext Context { get; init; } = new(); + + /// The assessment of a measurement in the window (OK, Bemærk, Reagér). + public Assessment Assess(MetricComparison metric) + { + ArgumentNullException.ThrowIfNull(metric); + return MetricAssessment.Assess(metric.Metric, metric.Points, Context); + } + + private static IReadOnlyList Points(List metrics, string metric) => + metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? []; + public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0 || Deadlocks.Count > 0 || TopQueries.Count > 0; @@ -103,6 +116,10 @@ public sealed record TimeWindowAnalysis Resolution = resolution, RawDays = rawDays, Metrics = metrics, + Context = MetricContext.From( + await repository.GetSnapshotAsync(id, "configuration", cancellationToken).ConfigureAwait(false), + await repository.GetSnapshotAsync(id, "server", cancellationToken).ConfigureAwait(false), + Points(metrics, Repository.Metrics.BatchRequestsPerSecond), Points(metrics, Repository.Metrics.CpuSqlServer), Points(metrics, Repository.Metrics.CpuOther)), Waits = WindowBaseline.CompareWaits(windowWaits, minutes, earlierWaits), Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false), Disks = WindowBaseline.CompareDisks(windowDisks, earlierDisks), diff --git a/src/SQLInsight.Reports/TimeWindowReport.cs b/src/SQLInsight.Reports/TimeWindowReport.cs index 143d410..37697b6 100644 --- a/src/SQLInsight.Reports/TimeWindowReport.cs +++ b/src/SQLInsight.Reports/TimeWindowReport.cs @@ -99,6 +99,22 @@ public static class TimeWindowReport 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" : ""); } + + section.AddParagraph("Vurdering", StyleNames.Heading2); + Assessments(section, analysis.Metrics.Where(m => m.Points.Count > 0).Select(m => (m.Label, analysis.Assess(m)))); + } + + /// The assessment of each measurement (OK, Bemærk, Reagér) with the next step, as on the web page. + private static void Assessments(Section section, IEnumerable<(string Label, Assessment Assessment)> items) + { + foreach (var (label, assessment) in items.Where(i => i.Assessment.Level != AssessmentLevel.Unknown)) + { + var paragraph = section.AddParagraph(); + var level = paragraph.AddFormattedText($"{assessment.LevelText} ", TextFormat.Bold); + level.Color = assessment.Level switch { AssessmentLevel.Act => Unusual, AssessmentLevel.Note => new Color(0xB5, 0x47, 0x08), _ => new Color(0x07, 0x94, 0x55) }; + paragraph.AddFormattedText(label + ": ", TextFormat.Bold); + paragraph.AddText(assessment.Summary + (assessment.NextStep is { } next ? " Næste skridt: " + next : "")); + } } private static void Waits(Section section, TimeWindowAnalysis analysis) @@ -164,6 +180,7 @@ public static class TimeWindowReport } section.AddParagraph("Latens i parentes: det normale for samme klokkeslæt de foregående uger.", "Muted"); + Assessments(section, analysis.Disks.Select(d => (d.Window.Volume, MetricAssessment.AssessDisk(d.Window)))); static string Normal(double? value) => value is { } v ? $" ({v.ToString("N1", Danish)})" : ""; } diff --git a/src/SQLInsight.Web/Components/Pages/Analysis.razor b/src/SQLInsight.Web/Components/Pages/Analysis.razor index f1b5aae..7017973 100644 --- a/src/SQLInsight.Web/Components/Pages/Analysis.razor +++ b/src/SQLInsight.Web/Components/Pages/Analysis.razor @@ -58,6 +58,7 @@

Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")

+
} @@ -117,7 +118,7 @@

Diskhastighed pr. drev

- +
diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index 601099b..6131629 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -47,10 +47,16 @@ Analysér et tidsrum
-
-
-
-
+ @foreach (var (metric, label, unit, decimals, _, max) in SQLInsight.Reports.TimeWindowAnalysis.MetricDefinitions) + { + var points = _metrics.GetValueOrDefault(metric) ?? []; +
+ + +
+ }
@@ -83,7 +89,7 @@ else

Diskhastighed pr. drev, seneste 24 timer

- + @if (_diskSeries.Count > 0) {
@@ -241,7 +247,8 @@ else private IReadOnlyList _resolved = []; private IReadOnlyList _notMeasurable = []; private IReadOnlyList _notApplicable = []; - private IReadOnlyList _cpuSql = [], _cpuOther = [], _batches = [], _ple = []; + private readonly Dictionary> _metrics = []; + private SQLInsight.Reports.MetricContext _context = new(); private IReadOnlyList _waits = []; private IReadOnlyList _files = []; private IReadOnlyList _disks = []; @@ -264,10 +271,15 @@ else var states = await Repository.GetRuleStatesAsync(id); _notMeasurable = states.Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList(); _notApplicable = states.Where(r => r.Outcome == RuleOutcome.NotApplicable).ToList(); - _cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day); - _cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day); - _batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day); - _ple = await Repository.GetMetricAsync(id, Metrics.PageLifeExpectancy, day); + foreach (var definition in SQLInsight.Reports.TimeWindowAnalysis.MetricDefinitions) + { + _metrics[definition.Metric] = await Repository.GetMetricAsync(id, definition.Metric, day); + } + + _context = SQLInsight.Reports.MetricContext.From( + await Repository.GetSnapshotAsync(id, "configuration"), + await Repository.GetSnapshotAsync(id, "server"), + _metrics[Metrics.BatchRequestsPerSecond], _metrics[Metrics.CpuSqlServer], _metrics[Metrics.CpuOther]); _waits = await Repository.GetTopWaitsAsync(id, day, 12); _files = await Repository.GetFileLatencyAsync(id, day); _disks = (await Repository.GetDiskActivityAsync(id, day, DateTimeOffset.UtcNow)).Select(d => new SQLInsight.Reports.DiskComparison(d, null)).ToList(); diff --git a/src/SQLInsight.Web/Components/Shared/AssessmentNote.razor b/src/SQLInsight.Web/Components/Shared/AssessmentNote.razor new file mode 100644 index 0000000..60bd5d1 --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/AssessmentNote.razor @@ -0,0 +1,27 @@ +@* The assessment of a measurement and what it means. Links are relative to the instance page. *@ +
+ @Assessment.LevelText + @Assessment.Summary + @if (Assessment.NextStep is { } next) + { +
Næste skridt: @next @if (Assessment.Link is { } link && InstanceName is not null) { Se mere }
+ } + @if (Explanation is { } text) + { +
Hvad betyder det?

@text

+ } +
+ +@code { + [Parameter, EditorRequired] public Assessment Assessment { get; set; } = default!; + [Parameter] public string? Explanation { get; set; } + [Parameter] public string? InstanceName { get; set; } + + private string CssClass => Assessment.Level switch + { + AssessmentLevel.Ok => "ok", + AssessmentLevel.Note => "note", + AssessmentLevel.Act => "act", + _ => "", + }; +} diff --git a/src/SQLInsight.Web/Components/Shared/DiskTable.razor b/src/SQLInsight.Web/Components/Shared/DiskTable.razor index 80eae69..f25c69f 100644 --- a/src/SQLInsight.Web/Components/Shared/DiskTable.razor +++ b/src/SQLInsight.Web/Components/Shared/DiskTable.razor @@ -9,7 +9,7 @@ else DrevFilerLæs IOPSSkriv IOPS - Læs MB/sSkriv MB/sGns. læsGns. skriv + Læs MB/sSkriv MB/sGns. læsGns. skrivVurdering @@ -25,10 +25,12 @@ else @Display.Number(w.WriteMbPerSecond ?? 0, 1) @Ms(w.AverageReadMs)@Normal(d.Normal?.AverageReadMs) @Ms(w.AverageWriteMs)@Normal(d.Normal?.AverageWriteMs) + } +
Hvad betyder det?

@SQLInsight.Reports.MetricAssessment.Explanation("disk.read")

@SQLInsight.Reports.MetricAssessment.Explanation("disk.write.log")

@SQLInsight.Reports.MetricAssessment.Explanation("disk.write")

Latens er gennemsnitstiden pr. læsning/skrivning set fra SQL Server (sys.dm_io_virtual_file_stats). Datafiler bør ligge under 10-20 ms, logfiler under 5 ms.@(ShowNormal ? " I parentes: det normale for samme klokkeslæt de foregående uger." : "")

} @@ -36,6 +38,7 @@ else [Parameter, EditorRequired] public IReadOnlyList Disks { get; set; } = []; [Parameter] public string Empty { get; set; } = "Ingen målinger pr. drev endnu."; [Parameter] public bool ShowNormal { get; set; } + [Parameter] public string? InstanceName { get; set; } private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–"; diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index 2af74b0..d2fc121 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -196,3 +196,11 @@ pre.sql { white-space: pre-wrap; word-break: break-word; max-height: 9rem; overf details.not-applicable { margin-top: 1rem; } details.not-applicable summary { cursor: pointer; color: var(--text-2); font-weight: 600; } details.not-applicable > table { margin-top: .75rem; } + +/* Assessment of a measurement: OK, Bemærk, Reagér. */ +.assess { display: inline-block; padding: .05rem .5rem; border-radius: 999px; font-size: .75rem; font-weight: 600; color: var(--c, var(--info)); background: var(--c-soft, var(--info-soft)); } +.assess.ok { --c: var(--ok); --c-soft: var(--ok-soft); } +.assess.note { --c: var(--medium); --c-soft: var(--medium-soft); } +.assess.act { --c: var(--critical); --c-soft: var(--critical-soft); } +.assessment { margin: .4rem 0 0; font-size: .85rem; } +.assessment details { border-top: 0; padding: .3rem 0 0; } diff --git a/tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs b/tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs new file mode 100644 index 0000000..2da7f22 --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs @@ -0,0 +1,107 @@ +using SQLInsight.Core.Data; +using SQLInsight.Reports; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Tests; + +public class MetricAssessmentTests +{ + private static readonly DateTime Start = new(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc); + + private static List Series(params double[] values) => + values.Select((v, i) => new MetricPoint(Start.AddMinutes(5 * i), v)).ToList(); + + [Fact] + public void Ple_threshold_scales_with_the_buffer_pool() + { + var ple = Series(1000, 1100, 1050, 1000); + + Assert.Equal(AssessmentLevel.Ok, MetricAssessment.Assess(Metrics.PageLifeExpectancy, ple, new MetricContext(BufferPoolGb: 8, MaxServerMemorySet: true)).Level); + var big = MetricAssessment.Assess(Metrics.PageLifeExpectancy, ple, new MetricContext(BufferPoolGb: 64, MaxServerMemorySet: true)); // 4800 s + Assert.Equal(AssessmentLevel.Act, big.Level); + Assert.Contains("4.800", big.Summary, StringComparison.Ordinal); + } + + [Fact] + public void Ple_dips_of_more_than_half_are_noted_and_unset_max_server_memory_is_mentioned() + { + var dip = MetricAssessment.Assess(Metrics.PageLifeExpectancy, Series(3000, 3100, 900, 3000, 3050), new MetricContext(BufferPoolGb: 4, MaxServerMemorySet: true)); + var unset = MetricAssessment.Assess(Metrics.PageLifeExpectancy, Series(3000, 3100), new MetricContext(BufferPoolGb: 4, MaxServerMemorySet: false)); + + Assert.Equal(AssessmentLevel.Note, dip.Level); + Assert.Contains("50 %", dip.Summary, StringComparison.Ordinal); + Assert.Equal(AssessmentLevel.Note, unset.Level); + Assert.Contains("max server memory", unset.Summary, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, AssessmentLevel.Ok)] + [InlineData(15, AssessmentLevel.Note)] + [InlineData(30, AssessmentLevel.Act)] + public void Compilations_are_judged_against_batches(double compilations, AssessmentLevel expected) + { + var context = new MetricContext(BatchRequests: Series(100, 100, 100)); + + Assert.Equal(expected, MetricAssessment.Assess(Metrics.CompilationsPerSecond, Series(compilations, compilations, compilations), context).Level); + } + + [Fact] + public void Counts_above_zero_must_be_sustained_to_act() + { + Assert.Equal(AssessmentLevel.Ok, MetricAssessment.Assess(Metrics.MemoryGrantsPending, Series(0, 0, 0)).Level); + Assert.Equal(AssessmentLevel.Note, MetricAssessment.Assess(Metrics.MemoryGrantsPending, Series(0, 2, 0, 0)).Level); + var blocked = MetricAssessment.Assess(Metrics.ProcessesBlocked, Series(0, 3, 4, 5, 0)); + Assert.Equal(AssessmentLevel.Act, blocked.Level); + Assert.Equal("analyse#blocking", blocked.Link); + } + + [Fact] + public void High_cpu_from_other_processes_points_at_them() + { + var sql = Series(30, 35, 30, 32); + var other = Series(55, 55, 60, 58); + + var cpu = MetricAssessment.Assess(Metrics.CpuSqlServer, sql, new MetricContext(CpuOther: other)); + var own = MetricAssessment.Assess(Metrics.CpuSqlServer, Series(90, 92, 95, 91), new MetricContext(CpuOther: Series(2, 2, 2, 2))); + + Assert.Equal(AssessmentLevel.Act, cpu.Level); + Assert.Contains("andre processer", cpu.Summary, StringComparison.Ordinal); + Assert.Equal(AssessmentLevel.Act, own.Level); + Assert.Contains("SQL Server bruger", own.Summary, StringComparison.Ordinal); + Assert.Equal(AssessmentLevel.Ok, MetricAssessment.Assess(Metrics.CpuSqlServer, Series(20, 25), new MetricContext(CpuOther: Series(1, 1))).Level); + } + + [Theory] + [InlineData(5, 3, 1, AssessmentLevel.Ok)] + [InlineData(15, 3, 1, AssessmentLevel.Note)] + [InlineData(30, 3, 1, AssessmentLevel.Act)] + [InlineData(1, 8, 0, AssessmentLevel.Note)] // log drive: 8 ms per write + [InlineData(1, 20, 0, AssessmentLevel.Act)] + public void Disk_latency_thresholds(double readMs, double writeMs, int dataFiles, AssessmentLevel expected) + { + var disk = new DiskActivity("L:", dataFiles, 1, 1000, 1000, 0, 0, (long)(readMs * 1000), (long)(writeMs * 1000), 300); + + Assert.Equal(expected, MetricAssessment.AssessDisk(disk).Level); + } + + [Fact] + public void Every_metric_has_an_explanation_and_no_data_is_unknown() + { + foreach (var (metric, _, _, _, _, _) in TimeWindowAnalysis.MetricDefinitions) + { + Assert.NotEqual("Ingen forklaring.", MetricAssessment.Explanation(metric)); + Assert.Equal(AssessmentLevel.Unknown, MetricAssessment.Assess(metric, []).Level); + } + } + + [Fact] + public void Context_uses_max_server_memory_capped_by_physical_memory() + { + var config = new ConfigurationSnapshot { CapturedAt = DateTimeOffset.UtcNow, Options = [new ConfigurationOption("max server memory (MB)", 28672, 28672)] }; + var server = new ServerSnapshot { CapturedAt = DateTimeOffset.UtcNow, LogicalCpuCount = 8, PhysicalMemoryMb = 32768, SqlServerStartTime = Start }; + var unset = config with { Options = [new ConfigurationOption("max server memory (MB)", 2147483647, 2147483647)] }; + + Assert.Equal(28, MetricContext.From(config, server).BufferPoolGb); + Assert.Equal((32.0, false), (MetricContext.From(unset, server).BufferPoolGb!.Value, MetricContext.From(unset, server).MaxServerMemorySet!.Value)); + } +} diff --git a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs index 19348b8..24ac547 100644 --- a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs +++ b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs @@ -121,6 +121,7 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime Assert.Equal(90, cpu.Average); Assert.Equal(30, cpu.NormalAverage); Assert.True(cpu.IsUnusual); + Assert.Equal(AssessmentLevel.Act, analysis.Assess(cpu).Level); Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text); Assert.Single(analysis.Blocking); } @@ -148,6 +149,8 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime Assert.Contains("LCK_M_X", html, StringComparison.Ordinal); Assert.Contains("Diskhastighed pr. drev", html, StringComparison.Ordinal); Assert.Contains("class=\"unusual\"", html, StringComparison.Ordinal); + Assert.Contains("Hvad betyder det?", html, StringComparison.Ordinal); + Assert.Contains("class=\"assess act\"", html, StringComparison.Ordinal); // SQL Server CPU 90 % 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))}"); @@ -158,6 +161,8 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime var instancePage = await client.GetStringAsync($"/instanser/{name}"); Assert.Contains($"/instanser/{name}/analyse", instancePage, StringComparison.Ordinal); + Assert.Contains("Hvad betyder det?", instancePage, StringComparison.Ordinal); + Assert.Contains("Memory grants pending", 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);