Explain and assess measurements for the customer
MetricAssessment rates CPU (SQL/other), batch requests, compilations (against batches), page life expectancy (300 s per 4 GB buffer pool, average and dips over 50 %, max server memory unset), user connections, memory grants pending, processes blocked and disk latency as OK, Bemærk or Reagér with a concrete next step and link, plus a plain Danish "Hvad betyder det?" for each. Shown under every graph on the instance page (now all eight metrics) and in the analysis, per drive in the disk table, and in the analysis PDF. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
20671bfb06
commit
7a908cc28b
11 changed files with 558 additions and 12 deletions
|
|
@ -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.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
330
src/SQLInsight.Reports/MetricAssessment.cs
Normal file
330
src/SQLInsight.Reports/MetricAssessment.cs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
|
||||||
|
namespace SQLInsight.Reports;
|
||||||
|
|
||||||
|
public enum AssessmentLevel
|
||||||
|
{
|
||||||
|
/// <summary>Not enough data to say.</summary>
|
||||||
|
Unknown,
|
||||||
|
Ok,
|
||||||
|
|
||||||
|
/// <summary>"Bemærk": worth knowing, not urgent.</summary>
|
||||||
|
Note,
|
||||||
|
|
||||||
|
/// <summary>"Reagér": act on it.</summary>
|
||||||
|
Act,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="Summary">What the numbers say, in one or two sentences.</param>
|
||||||
|
/// <param name="NextStep">The concrete next action; null when there is nothing to do.</param>
|
||||||
|
/// <param name="Link">A page in SQLInsight Pro (relative to the instance page) or documentation for the next step.</param>
|
||||||
|
public sealed record Assessment(AssessmentLevel Level, string Summary, string? NextStep = null, string? Link = null)
|
||||||
|
{
|
||||||
|
public string LevelText => MetricAssessment.LevelText(Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What is known about the instance besides the measurement itself.</summary>
|
||||||
|
/// <param name="BufferPoolGb">The memory SQL Server may use for data: max server memory, or else the server's memory.</param>
|
||||||
|
/// <param name="MaxServerMemorySet">False when max server memory is SQL Server's default (unlimited).</param>
|
||||||
|
public sealed record MetricContext(
|
||||||
|
double? BufferPoolGb = null,
|
||||||
|
bool? MaxServerMemorySet = null,
|
||||||
|
IReadOnlyList<MetricPoint>? BatchRequests = null,
|
||||||
|
IReadOnlyList<MetricPoint>? CpuSql = null,
|
||||||
|
IReadOnlyList<MetricPoint>? CpuOther = null)
|
||||||
|
{
|
||||||
|
public static MetricContext From(ConfigurationSnapshot? configuration, ServerSnapshot? server,
|
||||||
|
IReadOnlyList<MetricPoint>? batchRequests = null, IReadOnlyList<MetricPoint>? cpuSql = null, IReadOnlyList<MetricPoint>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class MetricAssessment
|
||||||
|
{
|
||||||
|
/// <summary>The classic rule of thumb, scaled: 300 seconds per 4 GB of buffer pool.</summary>
|
||||||
|
public const double PleSecondsPer4Gb = 300;
|
||||||
|
|
||||||
|
/// <summary>A count above 0 in this many consecutive samples is sustained, not a blip.</summary>
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>"Hvad betyder det?" for a metric, in plain Danish.</summary>
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The assessment of a metric's samples in a period.</summary>
|
||||||
|
public static Assessment Assess(string metric, IReadOnlyList<MetricPoint> 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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Disk latency on a drive: data reads, data writes, log writes.</summary>
|
||||||
|
public static Assessment AssessDisk(DiskActivity disk)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(disk);
|
||||||
|
var worst = AssessmentLevel.Unknown;
|
||||||
|
var parts = new List<string>();
|
||||||
|
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<MetricPoint> sql, IReadOnlyList<MetricPoint>? other)
|
||||||
|
{
|
||||||
|
IReadOnlyList<MetricPoint> 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<MetricPoint> other, IReadOnlyList<MetricPoint>? 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<MetricPoint> 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<MetricPoint> compilations, IReadOnlyList<MetricPoint>? 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<MetricPoint> 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<MetricPoint> 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<MetricPoint> Combine(IReadOnlyList<MetricPoint> a, IReadOnlyList<MetricPoint> 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<MetricPoint> points, Func<double, bool> 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<MetricPoint> points) => points.Count == 0 ? 0 : points.Average(p => p.Value);
|
||||||
|
|
||||||
|
private static double Peak(IReadOnlyList<MetricPoint> points) => points.Count == 0 ? 0 : points.Max(p => p.Value);
|
||||||
|
|
||||||
|
private static double Median(IReadOnlyList<MetricPoint> 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);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,19 @@ public sealed record TimeWindowAnalysis
|
||||||
/// <summary>The heaviest statements. Their text is customer data: only for the local UI and PDF.</summary>
|
/// <summary>The heaviest statements. Their text is customer data: only for the local UI and PDF.</summary>
|
||||||
public required IReadOnlyList<TopQuery> TopQueries { get; init; }
|
public required IReadOnlyList<TopQuery> TopQueries { get; init; }
|
||||||
|
|
||||||
|
/// <summary>What the assessment of the measurements needs besides them (buffer pool size, max server memory).</summary>
|
||||||
|
public MetricContext Context { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>The assessment of a measurement in the window (OK, Bemærk, Reagér).</summary>
|
||||||
|
public Assessment Assess(MetricComparison metric)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(metric);
|
||||||
|
return MetricAssessment.Assess(metric.Metric, metric.Points, Context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<MetricPoint> Points(List<MetricComparison> 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
|
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;
|
|| Deadlocks.Count > 0 || TopQueries.Count > 0;
|
||||||
|
|
||||||
|
|
@ -103,6 +116,10 @@ public sealed record TimeWindowAnalysis
|
||||||
Resolution = resolution,
|
Resolution = resolution,
|
||||||
RawDays = rawDays,
|
RawDays = rawDays,
|
||||||
Metrics = metrics,
|
Metrics = metrics,
|
||||||
|
Context = MetricContext.From(
|
||||||
|
await repository.GetSnapshotAsync<Core.Data.ConfigurationSnapshot>(id, "configuration", cancellationToken).ConfigureAwait(false),
|
||||||
|
await repository.GetSnapshotAsync<Core.Data.ServerSnapshot>(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),
|
Waits = WindowBaseline.CompareWaits(windowWaits, minutes, earlierWaits),
|
||||||
Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false),
|
||||||
Disks = WindowBaseline.CompareDisks(windowDisks, earlierDisks),
|
Disks = WindowBaseline.CompareDisks(windowDisks, earlierDisks),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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" : "");
|
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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The assessment of each measurement (OK, Bemærk, Reagér) with the next step, as on the web page.</summary>
|
||||||
|
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)
|
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");
|
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)})" : "";
|
static string Normal(double? value) => value is { } v ? $" ({v.ToString("N1", Danish)})" : "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
<div class="panel @(m.IsUnusual ? "unusual" : "")">
|
<div class="panel @(m.IsUnusual ? "unusual" : "")">
|
||||||
<Sparkline Points="m.Points" Label="@m.Label" Unit="@m.Unit" Max="m.Max" Decimals="m.Decimals" />
|
<Sparkline Points="m.Points" Label="@m.Label" Unit="@m.Unit" Max="m.Max" Decimals="m.Decimals" />
|
||||||
<p class="muted small">Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")</p>
|
<p class="muted small">Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")</p>
|
||||||
|
<AssessmentNote Assessment="_analysis.Assess(m)" Explanation="@MetricAssessment.Explanation(m.Metric)" InstanceName="@_instance.Name" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,7 +118,7 @@
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head"><h2>Diskhastighed pr. drev</h2></div>
|
<div class="card-head"><h2>Diskhastighed pr. drev</h2></div>
|
||||||
<DiskTable Disks="_analysis.Disks" ShowNormal="true" Empty="Ingen målinger pr. drev i tidsrummet (indsamles fra version 1.0.5)." />
|
<DiskTable Disks="_analysis.Disks" ShowNormal="true" InstanceName="@_instance.Name" Empty="Ingen målinger pr. drev i tidsrummet (indsamles fra version 1.0.5)." />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,16 @@
|
||||||
<a class="button" href="@AnalysisUrl(null)">Analysér et tidsrum</a>
|
<a class="button" href="@AnalysisUrl(null)">Analysér et tidsrum</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div class="panel"><Sparkline Points="_cpuSql" Label="SQL Server CPU" Unit=" %" Max="100" /></div>
|
@foreach (var (metric, label, unit, decimals, _, max) in SQLInsight.Reports.TimeWindowAnalysis.MetricDefinitions)
|
||||||
<div class="panel"><Sparkline Points="_cpuOther" Label="CPU brugt af andre processer" Unit=" %" Max="100" Empty="kan ikke måles på Linux, eller endnu ikke nok målinger." /></div>
|
{
|
||||||
<div class="panel"><Sparkline Points="_batches" Label="Batch requests pr. sekund" Decimals="1" /></div>
|
var points = _metrics.GetValueOrDefault(metric) ?? [];
|
||||||
<div class="panel"><Sparkline Points="_ple" Label="Page life expectancy (s)" /></div>
|
<div class="panel">
|
||||||
|
<Sparkline Points="points" Label="@label" Unit="@unit" Max="max" Decimals="decimals"
|
||||||
|
Empty="@(metric == Metrics.CpuOther ? "kan ikke måles på Linux, eller endnu ikke nok målinger." : "endnu ikke nok målinger.")" />
|
||||||
|
<AssessmentNote Assessment="SQLInsight.Reports.MetricAssessment.Assess(metric, points, _context)"
|
||||||
|
Explanation="@SQLInsight.Reports.MetricAssessment.Explanation(metric)" InstanceName="@_instance.Name" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card section">
|
<section class="card section">
|
||||||
|
|
@ -83,7 +89,7 @@ else
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head"><h2>Diskhastighed pr. drev, seneste 24 timer</h2></div>
|
<div class="card-head"><h2>Diskhastighed pr. drev, seneste 24 timer</h2></div>
|
||||||
<DiskTable Disks="_disks" Empty="Endnu ingen målinger pr. drev (kræver to målinger efter opgradering til 1.0.5)." />
|
<DiskTable Disks="_disks" InstanceName="@_instance.Name" Empty="Endnu ingen målinger pr. drev (kræver to målinger efter opgradering til 1.0.5)." />
|
||||||
@if (_diskSeries.Count > 0)
|
@if (_diskSeries.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
|
|
@ -241,7 +247,8 @@ else
|
||||||
private IReadOnlyList<StoredFinding> _resolved = [];
|
private IReadOnlyList<StoredFinding> _resolved = [];
|
||||||
private IReadOnlyList<StoredRuleState> _notMeasurable = [];
|
private IReadOnlyList<StoredRuleState> _notMeasurable = [];
|
||||||
private IReadOnlyList<StoredRuleState> _notApplicable = [];
|
private IReadOnlyList<StoredRuleState> _notApplicable = [];
|
||||||
private IReadOnlyList<MetricPoint> _cpuSql = [], _cpuOther = [], _batches = [], _ple = [];
|
private readonly Dictionary<string, IReadOnlyList<MetricPoint>> _metrics = [];
|
||||||
|
private SQLInsight.Reports.MetricContext _context = new();
|
||||||
private IReadOnlyList<WaitTotal> _waits = [];
|
private IReadOnlyList<WaitTotal> _waits = [];
|
||||||
private IReadOnlyList<FileLatency> _files = [];
|
private IReadOnlyList<FileLatency> _files = [];
|
||||||
private IReadOnlyList<SQLInsight.Reports.DiskComparison> _disks = [];
|
private IReadOnlyList<SQLInsight.Reports.DiskComparison> _disks = [];
|
||||||
|
|
@ -264,10 +271,15 @@ else
|
||||||
var states = await Repository.GetRuleStatesAsync(id);
|
var states = await Repository.GetRuleStatesAsync(id);
|
||||||
_notMeasurable = states.Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList();
|
_notMeasurable = states.Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList();
|
||||||
_notApplicable = states.Where(r => r.Outcome == RuleOutcome.NotApplicable).ToList();
|
_notApplicable = states.Where(r => r.Outcome == RuleOutcome.NotApplicable).ToList();
|
||||||
_cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day);
|
foreach (var definition in SQLInsight.Reports.TimeWindowAnalysis.MetricDefinitions)
|
||||||
_cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day);
|
{
|
||||||
_batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day);
|
_metrics[definition.Metric] = await Repository.GetMetricAsync(id, definition.Metric, day);
|
||||||
_ple = await Repository.GetMetricAsync(id, Metrics.PageLifeExpectancy, day);
|
}
|
||||||
|
|
||||||
|
_context = SQLInsight.Reports.MetricContext.From(
|
||||||
|
await Repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration"),
|
||||||
|
await Repository.GetSnapshotAsync<ServerSnapshot>(id, "server"),
|
||||||
|
_metrics[Metrics.BatchRequestsPerSecond], _metrics[Metrics.CpuSqlServer], _metrics[Metrics.CpuOther]);
|
||||||
_waits = await Repository.GetTopWaitsAsync(id, day, 12);
|
_waits = await Repository.GetTopWaitsAsync(id, day, 12);
|
||||||
_files = await Repository.GetFileLatencyAsync(id, day);
|
_files = await Repository.GetFileLatencyAsync(id, day);
|
||||||
_disks = (await Repository.GetDiskActivityAsync(id, day, DateTimeOffset.UtcNow)).Select(d => new SQLInsight.Reports.DiskComparison(d, null)).ToList();
|
_disks = (await Repository.GetDiskActivityAsync(id, day, DateTimeOffset.UtcNow)).Select(d => new SQLInsight.Reports.DiskComparison(d, null)).ToList();
|
||||||
|
|
|
||||||
27
src/SQLInsight.Web/Components/Shared/AssessmentNote.razor
Normal file
27
src/SQLInsight.Web/Components/Shared/AssessmentNote.razor
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
@* The assessment of a measurement and what it means. Links are relative to the instance page. *@
|
||||||
|
<div class="assessment">
|
||||||
|
<span class="assess @CssClass">@Assessment.LevelText</span>
|
||||||
|
@Assessment.Summary
|
||||||
|
@if (Assessment.NextStep is { } next)
|
||||||
|
{
|
||||||
|
<div><strong>Næste skridt:</strong> @next @if (Assessment.Link is { } link && InstanceName is not null) { <a href="/instanser/@Uri.EscapeDataString(InstanceName)/@link">Se mere</a> }</div>
|
||||||
|
}
|
||||||
|
@if (Explanation is { } text)
|
||||||
|
{
|
||||||
|
<details><summary>Hvad betyder det?</summary><p>@text</p></details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ else
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Drev</th><th class="num">Filer</th><th class="num">Læs IOPS</th><th class="num">Skriv IOPS</th>
|
<th>Drev</th><th class="num">Filer</th><th class="num">Læs IOPS</th><th class="num">Skriv IOPS</th>
|
||||||
<th class="num">Læs MB/s</th><th class="num">Skriv MB/s</th><th class="num">Gns. læs</th><th class="num">Gns. skriv</th>
|
<th class="num">Læs MB/s</th><th class="num">Skriv MB/s</th><th class="num">Gns. læs</th><th class="num">Gns. skriv</th><th>Vurdering</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -25,10 +25,12 @@ else
|
||||||
<td class="num">@Display.Number(w.WriteMbPerSecond ?? 0, 1)</td>
|
<td class="num">@Display.Number(w.WriteMbPerSecond ?? 0, 1)</td>
|
||||||
<td class="num @(w.AverageReadMs >= 20 || Worse(w.AverageReadMs, d.Normal?.AverageReadMs) ? "bad" : "")">@Ms(w.AverageReadMs)@Normal(d.Normal?.AverageReadMs)</td>
|
<td class="num @(w.AverageReadMs >= 20 || Worse(w.AverageReadMs, d.Normal?.AverageReadMs) ? "bad" : "")">@Ms(w.AverageReadMs)@Normal(d.Normal?.AverageReadMs)</td>
|
||||||
<td class="num @(w.AverageWriteMs >= (w.DataFiles == 0 && w.LogFiles > 0 ? 5 : 20) || Worse(w.AverageWriteMs, d.Normal?.AverageWriteMs) ? "bad" : "")">@Ms(w.AverageWriteMs)@Normal(d.Normal?.AverageWriteMs)</td>
|
<td class="num @(w.AverageWriteMs >= (w.DataFiles == 0 && w.LogFiles > 0 ? 5 : 20) || Worse(w.AverageWriteMs, d.Normal?.AverageWriteMs) ? "bad" : "")">@Ms(w.AverageWriteMs)@Normal(d.Normal?.AverageWriteMs)</td>
|
||||||
|
<td><AssessmentNote Assessment="SQLInsight.Reports.MetricAssessment.AssessDisk(w)" InstanceName="@InstanceName" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<details><summary>Hvad betyder det?</summary><p>@SQLInsight.Reports.MetricAssessment.Explanation("disk.read")</p><p>@SQLInsight.Reports.MetricAssessment.Explanation("disk.write.log")</p><p>@SQLInsight.Reports.MetricAssessment.Explanation("disk.write")</p></details>
|
||||||
<p class="muted">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." : "")</p>
|
<p class="muted">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." : "")</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ else
|
||||||
[Parameter, EditorRequired] public IReadOnlyList<SQLInsight.Reports.DiskComparison> Disks { get; set; } = [];
|
[Parameter, EditorRequired] public IReadOnlyList<SQLInsight.Reports.DiskComparison> Disks { get; set; } = [];
|
||||||
[Parameter] public string Empty { get; set; } = "Ingen målinger pr. drev endnu.";
|
[Parameter] public string Empty { get; set; } = "Ingen målinger pr. drev endnu.";
|
||||||
[Parameter] public bool ShowNormal { get; set; }
|
[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" : "–";
|
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { margin-top: 1rem; }
|
||||||
details.not-applicable summary { cursor: pointer; color: var(--text-2); font-weight: 600; }
|
details.not-applicable summary { cursor: pointer; color: var(--text-2); font-weight: 600; }
|
||||||
details.not-applicable > table { margin-top: .75rem; }
|
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; }
|
||||||
|
|
|
||||||
107
tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs
Normal file
107
tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs
Normal file
|
|
@ -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<MetricPoint> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -121,6 +121,7 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
Assert.Equal(90, cpu.Average);
|
Assert.Equal(90, cpu.Average);
|
||||||
Assert.Equal(30, cpu.NormalAverage);
|
Assert.Equal(30, cpu.NormalAverage);
|
||||||
Assert.True(cpu.IsUnusual);
|
Assert.True(cpu.IsUnusual);
|
||||||
|
Assert.Equal(AssessmentLevel.Act, analysis.Assess(cpu).Level);
|
||||||
Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text);
|
Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text);
|
||||||
Assert.Single(analysis.Blocking);
|
Assert.Single(analysis.Blocking);
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +149,8 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
Assert.Contains("LCK_M_X", html, StringComparison.Ordinal);
|
Assert.Contains("LCK_M_X", html, StringComparison.Ordinal);
|
||||||
Assert.Contains("Diskhastighed pr. drev", html, StringComparison.Ordinal);
|
Assert.Contains("Diskhastighed pr. drev", html, StringComparison.Ordinal);
|
||||||
Assert.Contains("class=\"unusual\"", 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);
|
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))}");
|
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}");
|
var instancePage = await client.GetStringAsync($"/instanser/{name}");
|
||||||
Assert.Contains($"/instanser/{name}/analyse", instancePage, StringComparison.Ordinal);
|
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)}");
|
using var pdf = await client.GetAsync($"/analyse.pdf?instans={name}&{Window(from, to)}");
|
||||||
Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
|
Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue