(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
| Drev | Filer | Læs IOPS | Skriv IOPS |
- Læs MB/s | Skriv MB/s | Gns. læs | Gns. skriv |
+ Læs MB/s | Skriv MB/s | Gns. læs | Gns. skriv | Vurdering |
@@ -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);