Compare commits

..

No commits in common. "e508b0c702962f60910174b91667457acc299d45" and "e1d45e1734e4f6d07cb5ca1f13e25ba531d9fe7e" have entirely different histories.

49 changed files with 45 additions and 2348 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product> <Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright> <Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. --> <!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.0.3</Version> <Version>1.0.2</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))"> <PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

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

View file

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

View file

@ -12,10 +12,6 @@ SQLInsight Pro overvåger alle versioner fra SQL Server 2000. Denne side beskriv
Agenten bruger som standard `Provider=Auto`: den prøver SqlClient først og falder kun tilbage til ODBC, når fejlen ligner en versions- eller TLS-inkompatibilitet (`LegacyFailureClassifier`). Login- og netværksfejl giver ikke fallback, for så ville den rigtige fejl blive skjult. Når fallback bruges, logges en advarsel. Agenten bruger som standard `Provider=Auto`: den prøver SqlClient først og falder kun tilbage til ODBC, når fejlen ligner en versions- eller TLS-inkompatibilitet (`LegacyFailureClassifier`). Login- og netværksfejl giver ikke fallback, for så ville den rigtige fejl blive skjult. Når fallback bruges, logges en advarsel.
## Ikke relevant eller ikke målbar
Regler om noget, versionen ikke har (Query Store før 2016, system_health før 2008, hukommelseskolonner i plan cache før 2016 SP2 …), vises som **ikke relevant for SQL Server 20xx** i en sammenklappet liste på instansens side. De tæller ikke med i "ikke målbare" på oversigten, i API'et eller i rapportens kontroller. **Ikke målbar** er forbeholdt det, der burde kunne måles: manglende rettigheder, en fejl eller en indstilling (fx Always On, der ikke er slået til).
## Spike: det skal verificeres på rigtige VM'er ## Spike: det skal verificeres på rigtige VM'er
Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers. Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers.

View file

@ -1,7 +1,5 @@
# Rapporter # Rapporter
Hvad der skete i et bestemt tidsrum på én instans (Statspack-lignende, også som PDF) står i [analyse.md](analyse.md).
SQLInsight Pro laver en **driftsrapport** som PDF til ledelse, it-chef og revision. Den er skrevet til læsere, der ikke er DBA'er: SQLInsight Pro laver en **driftsrapport** som PDF til ledelse, it-chef og revision. Den er skrevet til læsere, der ikke er DBA'er:
- **Nøgletal**: instanser (og hvor mange der ikke kan nås), kritiske fund, fund med høj alvor, og nye og løste fund i perioden. - **Nøgletal**: instanser (og hvor mange der ikke kan nås), kritiske fund, fund med høj alvor, og nye og løste fund i perioden.

View file

@ -6,7 +6,7 @@
"title": "Fremmednøgler, som SQL Server ikke stoler på", "title": "Fremmednøgler, som SQL Server ikke stoler på",
"severity": "Low", "severity": "Low",
"scope": "Database", "scope": "Database",
"query": "SELECT s.name + '.' + o.name AS [table], fk.name AS [constraint] FROM sys.foreign_keys fk JOIN sys.objects o ON o.object_id = fk.parent_object_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE fk.is_not_trusted = 1 AND fk.is_disabled = 0", "query": "SELECT OBJECT_SCHEMA_NAME(parent_object_id) + '.' + OBJECT_NAME(parent_object_id) AS [table], name AS [constraint] FROM sys.foreign_keys WHERE is_not_trusted = 1 AND is_disabled = 0",
"subject": "constraint: {database}/{constraint}", "subject": "constraint: {database}/{constraint}",
"findingTitle": "{database}: fremmednøglen {constraint} på {table} er ikke betroet", "findingTitle": "{database}: fremmednøglen {constraint} på {table} er ikke betroet",
"explanation": "Nøglen er blevet slået til igen uden WITH CHECK, fx efter en dataindlæsning. Den håndhæves for nye rækker, men SQL Server kan ikke stole på eksisterende data, så optimizeren udnytter den ikke til at fjerne joins, og der kan ligge ugyldige data.", "explanation": "Nøglen er blevet slået til igen uden WITH CHECK, fx efter en dataindlæsning. Den håndhæves for nye rækker, men SQL Server kan ikke stole på eksisterende data, så optimizeren udnytter den ikke til at fjerne joins, og der kan ligge ugyldige data.",

View file

@ -127,16 +127,7 @@ public sealed partial class InstanceMonitor(
var missing = capabilities.Missing(collector.RequiredCapabilities); var missing = capabilities.Missing(collector.RequiredCapabilities);
if (missing.Count > 0) if (missing.Count > 0)
{ {
if (missing.All(InstanceCapabilities.IsInherent)) _data.MarkUnavailable(collector.DataType, $"Ikke målbar på {info.Release.DisplayName()}: kræver {string.Join(", ", missing)}.");
{
// The version lacks it (Query Store on 2005 …): not applicable, not a gap in the monitoring.
_data.MarkNotApplicable(collector.DataType, $"Ikke relevant for {info.Release.DisplayName()}: kræver {InstanceCapabilities.Describe(missing)}.");
}
else
{
_data.MarkUnavailable(collector.DataType, $"Ikke målbar på {info.Release.DisplayName()}: kræver {string.Join(", ", missing)}.");
}
return; return;
} }

View file

@ -18,7 +18,6 @@ public static class BuiltInCollectors
new PlanCacheCollector(), new PlanCacheCollector(),
new WaitStatsCollector(), new WaitStatsCollector(),
new FileIoCollector(), new FileIoCollector(),
new QueryStatsCollector(),
new PerformanceCounterCollector(), new PerformanceCounterCollector(),
new CpuCollector(), new CpuCollector(),
new BlockingCollector(), new BlockingCollector(),

View file

@ -13,8 +13,7 @@ public sealed class AgentJobCollector : Collector<AgentJobSnapshot>
internal const string RunningSql = "SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name LIKE N'SQLAgent - %'"; internal const string RunningSql = "SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name LIKE N'SQLAgent - %'";
internal const string JobsSql = """ internal const string JobsSql = """
DECLARE @since int; DECLARE @since int = CONVERT(int, CONVERT(char(8), DATEADD(day, -1, GETDATE()), 112));
SET @since = CONVERT(int, CONVERT(char(8), DATEADD(day, -1, GETDATE()), 112));
SELECT j.name, j.enabled, last_run.run_status, last_run.run_date, last_run.run_time, SELECT j.name, j.enabled, last_run.run_status, last_run.run_date, last_run.run_time,
failed_step.step_name, LEFT(failed_step.message, 2000), failures.n failed_step.step_name, LEFT(failed_step.message, 2000), failures.n
FROM msdb.dbo.sysjobs j FROM msdb.dbo.sysjobs j

View file

@ -14,13 +14,12 @@ public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
public override string Name => "plan-analysis"; public override string Name => "plan-analysis";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30); public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews, Capability.TextQueryPlan]; public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<PlanAnalysisSnapshot> CollectAsync(CollectorContext context, PlanAnalysisSnapshot? previous) protected override async Task<PlanAnalysisSnapshot> CollectAsync(CollectorContext context, PlanAnalysisSnapshot? previous)
{ {
var memory = context.Capabilities.Has(Capability.QueryStatsMemoryColumns); var memory = context.Capabilities.Has(Capability.QueryStatsMemoryColumns);
var queryHash = context.Instance.Version.Major >= 10; var statements = await Db.QueryAsync(context.Connection, BuildSql(memory), r => new AnalyzedStatement
var statements = await Db.QueryAsync(context.Connection, BuildSql(memory, queryHash), r => new AnalyzedStatement
{ {
QueryHash = r.String(0), QueryHash = r.String(0),
DatabaseName = r.NullableString(1), DatabaseName = r.NullableString(1),
@ -39,12 +38,8 @@ public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
return new PlanAnalysisSnapshot { CapturedAt = context.Now, Statements = statements }; return new PlanAnalysisSnapshot { CapturedAt = context.Now, Statements = statements };
} }
/// <param name="queryHash">query_hash exists from SQL Server 2008; on 2005 a statement is its batch and offsets.</param> internal static string BuildSql(bool memoryColumns)
internal static string BuildSql(bool memoryColumns, bool queryHash = true)
{ {
var key = queryHash
? "CONVERT(varchar(20), qs.query_hash, 1)"
: "sys.fn_varbintohexstr(qs.sql_handle) + ':' + CAST(qs.statement_start_offset AS varchar(12)) + ':' + CAST(qs.statement_end_offset AS varchar(12))";
var memory = memoryColumns var memory = memoryColumns
? "qs.total_spills, qs.total_grant_kb, qs.total_used_grant_kb" ? "qs.total_spills, qs.total_grant_kb, qs.total_used_grant_kb"
: "CAST(NULL AS bigint), CAST(NULL AS bigint), CAST(NULL AS bigint)"; : "CAST(NULL AS bigint), CAST(NULL AS bigint), CAST(NULL AS bigint)";
@ -55,7 +50,7 @@ public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
WHERE qs.last_execution_time >= DATEADD(hour, -24, GETDATE()) WHERE qs.last_execution_time >= DATEADD(hour, -24, GETDATE())
ORDER BY qs.total_worker_time DESC ORDER BY qs.total_worker_time DESC
) )
SELECT {key}, SELECT CONVERT(varchar(20), qs.query_hash, 1),
DB_NAME(CONVERT(int, pa.value)), DB_NAME(CONVERT(int, pa.value)),
LEFT(SUBSTRING(t.text, qs.statement_start_offset / 2 + 1, LEFT(SUBSTRING(t.text, qs.statement_start_offset / 2 + 1,
(CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1), 4000), (CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1), 4000),

View file

@ -1,131 +0,0 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Performance;
/// <summary>
/// Top SQL: cumulative statistics from <c>sys.dm_exec_query_stats</c> for the statements that ran since the previous
/// collection, so the time window analysis can show which statements used the CPU, time and reads. Kept light: only
/// the heaviest <see cref="TopPerMeasure"/> statements by CPU, duration, reads and executions, their text cut to
/// <see cref="QueryStat.MaxTextLength"/> characters, and no plan XML.
/// </summary>
public sealed class QueryStatsCollector : Collector<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>
{
public const int TopPerMeasure = 50;
/// <summary>
/// Statements are grouped by query_hash, so the same statement with different literals counts as one. The database
/// is the batch's own (procedures) or else the one it ran in (plan attribute dbid): ad hoc batches, as AX sends, have none.
/// </summary>
internal const string Sql2008 = """
WITH s AS (
SELECT qs.query_hash AS k,
SUM(qs.execution_count) AS execs, SUM(qs.total_worker_time) AS cpu, SUM(qs.total_elapsed_time) AS elapsed,
SUM(qs.total_logical_reads) AS lreads, SUM(qs.total_physical_reads) AS preads, SUM(qs.total_logical_writes) AS writes,
MIN(qs.creation_time) AS created, MAX(qs.last_execution_time) AS last_exec
FROM sys.dm_exec_query_stats qs
GROUP BY qs.query_hash
HAVING MAX(qs.last_execution_time) >= {since}),
t AS (
SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY cpu DESC) a
UNION SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY elapsed DESC) b
UNION SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY lreads DESC) c
UNION SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY execs DESC) d)
SELECT CONVERT(varchar(20), s.k, 1), s.execs, s.cpu, s.elapsed, s.lreads, s.preads, s.writes, s.created, s.last_exec,
DB_NAME(COALESCE(st.dbid, pa.dbid)),
CAST(SUBSTRING(st.text, r.statement_start_offset / 2 + 1,
CASE WHEN r.statement_end_offset = -1 THEN {length}
ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END) AS nvarchar({length})),
GETDATE()
FROM s JOIN t ON t.k = s.k
OUTER APPLY (SELECT TOP (1) q.sql_handle, q.plan_handle, q.statement_start_offset, q.statement_end_offset
FROM sys.dm_exec_query_stats q WHERE q.query_hash = s.k ORDER BY q.last_execution_time DESC) r
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) st
OUTER APPLY (SELECT CAST(a.value AS int) AS dbid FROM sys.dm_exec_plan_attributes(r.plan_handle) a WHERE a.attribute = N'dbid') pa
""";
/// <summary>SQL Server 2005 has no query_hash: a statement is its batch (sql_handle) and its offsets in it.</summary>
internal const string Sql2005 = """
WITH s AS (
SELECT qs.sql_handle AS h, qs.statement_start_offset AS so, qs.statement_end_offset AS eo,
SUM(qs.execution_count) AS execs, SUM(qs.total_worker_time) AS cpu, SUM(qs.total_elapsed_time) AS elapsed,
SUM(qs.total_logical_reads) AS lreads, SUM(qs.total_physical_reads) AS preads, SUM(qs.total_logical_writes) AS writes,
MIN(qs.creation_time) AS created, MAX(qs.last_execution_time) AS last_exec
FROM sys.dm_exec_query_stats qs
GROUP BY qs.sql_handle, qs.statement_start_offset, qs.statement_end_offset
HAVING MAX(qs.last_execution_time) >= {since}),
t AS (
SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY cpu DESC) a
UNION SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY elapsed DESC) b
UNION SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY lreads DESC) c
UNION SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY execs DESC) d)
SELECT sys.fn_varbintohexstr(s.h) + ':' + CAST(s.so AS varchar(12)) + ':' + CAST(s.eo AS varchar(12)),
s.execs, s.cpu, s.elapsed, s.lreads, s.preads, s.writes, s.created, s.last_exec,
DB_NAME(COALESCE(st.dbid, pa.dbid)),
CAST(SUBSTRING(st.text, s.so / 2 + 1, CASE WHEN s.eo = -1 THEN {length} ELSE (s.eo - s.so) / 2 + 1 END) AS nvarchar({length})),
GETDATE()
FROM s JOIN t ON t.h = s.h AND t.so = s.so AND t.eo = s.eo
OUTER APPLY sys.dm_exec_sql_text(s.h) st
OUTER APPLY (SELECT TOP (1) q.plan_handle FROM sys.dm_exec_query_stats q
WHERE q.sql_handle = s.h AND q.statement_start_offset = s.so AND q.statement_end_offset = s.eo) r
OUTER APPLY (SELECT CAST(a.value AS int) AS dbid FROM sys.dm_exec_plan_attributes(r.plan_handle) a WHERE a.attribute = N'dbid') pa
""";
public override string Name => "query-stats";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>> CollectAsync(
CollectorContext context, CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>? previous)
{
var version = context.Instance.Version;
var ct = context.CancellationToken;
var startTime = await Db.StartTimeAsync(context.Connection, version, ct).ConfigureAwait(false);
var sql = SqlFor(version, previous?.Snapshot is { } last && last.SqlServerStartTime == startTime ? last.ServerTime : null);
DateTime? serverTime = null;
var rows = await Db.QueryAsync(context.Connection, sql, r =>
{
serverTime ??= r.DateTime(11);
var text = r.NullableString(10);
return new QueryStat
{
Key = r.String(0),
Executions = r.Long(1),
WorkerTimeUs = r.Long(2),
ElapsedTimeUs = r.Long(3),
LogicalReads = r.Long(4),
PhysicalReads = r.Long(5),
LogicalWrites = r.Long(6),
CreatedAt = r.DateTime(7),
LastExecutionAt = r.DateTime(8),
DatabaseName = r.NullableString(9),
Text = text is { Length: > QueryStat.MaxTextLength } ? text[..QueryStat.MaxTextLength] : text,
};
}, ct).ConfigureAwait(false);
// No statement ran since the previous collection: the server clock still has to move forward.
serverTime ??= (await Db.QueryAsync(context.Connection, "SELECT GETDATE()", r => r.DateTime(0), ct).ConfigureAwait(false))[0];
var fetched = new QueryStatsSnapshot { CapturedAt = context.Now, ServerTime = serverTime.Value, SqlServerStartTime = startTime, Queries = rows };
var delta = QueryStatsDelta.Compute(previous?.Snapshot, fetched);
var kept = fetched with { Queries = QueryStatsDelta.Remember(previous?.Snapshot, rows, startTime) };
return new(kept, delta);
}
/// <summary>
/// The query for the version. <paramref name="since"/> is the server clock at the previous collection; the first
/// time, the last 15 minutes. A literal, not a parameter: ODBC (SQL Server 2005) only knows positional parameters.
/// </summary>
internal static string SqlFor(SqlServerVersion version, DateTime? since)
{
var sinceSql = since is { } value
? "CONVERT(datetime, '" + value.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture) + "', 126)"
: "DATEADD(minute, -15, GETDATE())";
return (version.Major >= 10 ? Sql2008 : Sql2005)
.Replace("{since}", sinceSql, StringComparison.Ordinal)
.Replace("{top}", TopPerMeasure.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)
.Replace("{length}", QueryStat.MaxTextLength.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
}
}

View file

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

View file

@ -2,20 +2,19 @@ namespace SQLInsight.Core.Collection;
/// <summary> /// <summary>
/// The latest data collected from one instance, keyed by data type. A collector that could not run records why, /// The latest data collected from one instance, keyed by data type. A collector that could not run records why,
/// so rules depending on it are reported as not measurable with that reason, or as not applicable when the /// so rules depending on it are reported as not measurable with that reason.
/// instance's SQL Server version does not have the data at all.
/// </summary> /// </summary>
public sealed class CollectedData public sealed class CollectedData
{ {
private readonly Dictionary<Type, object> _values = []; private readonly Dictionary<Type, object> _values = [];
private readonly Dictionary<Type, string> _unavailable = []; private readonly Dictionary<Type, string> _unavailable = [];
private readonly HashSet<Type> _notApplicable = [];
public void Set<T>(T value) public void Set<T>(T value)
where T : class where T : class
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
Set(typeof(T), value); _values[typeof(T)] = value;
_unavailable.Remove(typeof(T));
} }
public void Set(Type type, object value) public void Set(Type type, object value)
@ -24,7 +23,6 @@ public sealed class CollectedData
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
_values[type] = value; _values[type] = value;
_unavailable.Remove(type); _unavailable.Remove(type);
_notApplicable.Remove(type);
} }
public void MarkUnavailable(Type type, string reason) public void MarkUnavailable(Type type, string reason)
@ -32,21 +30,10 @@ public sealed class CollectedData
ArgumentNullException.ThrowIfNull(type); ArgumentNullException.ThrowIfNull(type);
_values.Remove(type); _values.Remove(type);
_unavailable[type] = reason; _unavailable[type] = reason;
_notApplicable.Remove(type);
} }
public void MarkUnavailable<T>(string reason) => MarkUnavailable(typeof(T), reason); public void MarkUnavailable<T>(string reason) => MarkUnavailable(typeof(T), reason);
/// <summary>
/// The data does not exist on this kind of instance (its SQL Server version lacks it): rules depending on it are
/// reported as not applicable rather than not measurable.
/// </summary>
public void MarkNotApplicable(Type type, string reason)
{
MarkUnavailable(type, reason);
_notApplicable.Add(type);
}
public bool TryGet<T>(out T value) public bool TryGet<T>(out T value)
where T : class where T : class
{ {
@ -62,7 +49,7 @@ public sealed class CollectedData
/// <summary> /// <summary>
/// Returns the data, or throws <see cref="DataUnavailableException"/> which the rule engine reports as /// Returns the data, or throws <see cref="DataUnavailableException"/> which the rule engine reports as
/// not measurable (or not applicable, see <see cref="MarkNotApplicable"/>). /// not measurable.
/// </summary> /// </summary>
public T Get<T>() public T Get<T>()
where T : class where T : class
@ -75,10 +62,7 @@ public sealed class CollectedData
throw new DataUnavailableException( throw new DataUnavailableException(
_unavailable.TryGetValue(typeof(T), out var reason) _unavailable.TryGetValue(typeof(T), out var reason)
? reason ? reason
: $"Der er endnu ikke indsamlet data af typen {typeof(T).Name}.") : $"Der er endnu ikke indsamlet data af typen {typeof(T).Name}.");
{
NotApplicable = _notApplicable.Contains(typeof(T)),
};
} }
} }
@ -100,7 +84,4 @@ public sealed class DataUnavailableException : Exception
: base(message, innerException) : base(message, innerException)
{ {
} }
/// <summary>The data cannot exist on this SQL Server version, as opposed to missing rights or a failure.</summary>
public bool NotApplicable { get; init; }
} }

View file

@ -1,156 +0,0 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// Cumulative statistics for the statements that ran since the previous collection (<c>sys.dm_exec_query_stats</c>,
/// grouped per statement), plus statements remembered from earlier collections so their totals can still be diffed.
/// </summary>
public sealed record QueryStatsSnapshot
{
/// <summary>Most statements remembered between collections; the least recently executed are forgotten first.</summary>
public const int MaxRemembered = 2000;
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The server's own clock (GETDATE()) at capture: <c>creation_time</c> and <c>last_execution_time</c> are in it.</summary>
public required DateTime ServerTime { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList<QueryStat> Queries { get; init; }
}
/// <summary>
/// One statement's totals since its first plan in cache was compiled. Times are in microseconds, as the DMV gives them.
/// </summary>
public sealed record QueryStat
{
/// <summary>query_hash as hex (SQL Server 2008+), or sql_handle:start:end on SQL Server 2005.</summary>
public required string Key { get; init; }
public string? DatabaseName { get; init; }
/// <summary>The statement, cut to <see cref="MaxTextLength"/> characters. Customer data: never sent to the hub.</summary>
public string? Text { get; init; }
public required long Executions { get; init; }
public required long WorkerTimeUs { get; init; }
public required long ElapsedTimeUs { get; init; }
public required long LogicalReads { get; init; }
public required long PhysicalReads { get; init; }
public required long LogicalWrites { get; init; }
/// <summary>The oldest cached plan's creation_time (server clock). A newer value means the plan was recompiled.</summary>
public required DateTime CreatedAt { get; init; }
/// <summary>last_execution_time (server clock).</summary>
public required DateTime LastExecutionAt { get; init; }
public const int MaxTextLength = 4000;
}
/// <summary>
/// What each statement used between two collections.
/// </summary>
public sealed record QueryStatsDelta
{
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public required IReadOnlyList<QueryStatDelta> Queries { get; init; }
/// <summary>
/// The change per statement. A statement only gets a delta when its starting point is known: it was seen before
/// with the same plan, or its plan was compiled after the previous collection (then all of its totals are new).
/// A plan recompiled or evicted in between, or counters that went backwards, start over: no negative numbers.
/// After an instance restart there is no delta at all.
/// </summary>
public static QueryStatsDelta? Compute(QueryStatsSnapshot? previous, QueryStatsSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous is null || previous.SqlServerStartTime != current.SqlServerStartTime || current.CapturedAt <= previous.CapturedAt)
{
return null;
}
var before = previous.Queries.ToDictionary(q => q.Key, StringComparer.Ordinal);
var changes = new List<QueryStatDelta>();
foreach (var now in current.Queries)
{
// Remembered from an earlier collection but not run since: nothing new.
if (now.LastExecutionAt < previous.ServerTime && before.ContainsKey(now.Key))
{
continue;
}
var newSinceLast = now.CreatedAt >= previous.ServerTime;
QueryStatDelta? delta;
if (before.TryGetValue(now.Key, out var then) && then.CreatedAt == now.CreatedAt && !WentBackwards(then, now))
{
delta = Difference(now, then);
}
else
{
delta = newSinceLast ? Difference(now, null) : null;
}
if (delta is { Executions: > 0 } or { WorkerTimeUs: > 0 })
{
changes.Add(delta);
}
}
return new QueryStatsDelta
{
From = previous.CapturedAt,
To = current.CapturedAt,
Queries = changes.OrderByDescending(q => q.WorkerTimeUs).ThenByDescending(q => q.ElapsedTimeUs).ToList(),
};
}
/// <summary>
/// The snapshot to keep for the next collection: this collection's statements plus the remembered ones not seen
/// now, at most <see cref="QueryStatsSnapshot.MaxRemembered"/>. After a restart the old ones are dropped.
/// </summary>
public static IReadOnlyList<QueryStat> Remember(QueryStatsSnapshot? previous, IReadOnlyList<QueryStat> current, DateTime sqlServerStartTime)
{
ArgumentNullException.ThrowIfNull(current);
var keys = current.Select(q => q.Key).ToHashSet(StringComparer.Ordinal);
var older = previous is null || previous.SqlServerStartTime != sqlServerStartTime
? []
: previous.Queries.Where(q => !keys.Contains(q.Key));
return current.Concat(older.OrderByDescending(q => q.LastExecutionAt))
.Take(QueryStatsSnapshot.MaxRemembered)
.ToList();
}
private static bool WentBackwards(QueryStat then, QueryStat now) =>
now.Executions < then.Executions || now.WorkerTimeUs < then.WorkerTimeUs || now.ElapsedTimeUs < then.ElapsedTimeUs
|| now.LogicalReads < then.LogicalReads || now.PhysicalReads < then.PhysicalReads || now.LogicalWrites < then.LogicalWrites;
private static QueryStatDelta Difference(QueryStat now, QueryStat? then) => new()
{
Key = now.Key,
DatabaseName = now.DatabaseName,
Text = now.Text,
Executions = now.Executions - (then?.Executions ?? 0),
WorkerTimeUs = now.WorkerTimeUs - (then?.WorkerTimeUs ?? 0),
ElapsedTimeUs = now.ElapsedTimeUs - (then?.ElapsedTimeUs ?? 0),
LogicalReads = now.LogicalReads - (then?.LogicalReads ?? 0),
PhysicalReads = now.PhysicalReads - (then?.PhysicalReads ?? 0),
LogicalWrites = now.LogicalWrites - (then?.LogicalWrites ?? 0),
};
}
public sealed record QueryStatDelta
{
public required string Key { get; init; }
public string? DatabaseName { get; init; }
/// <summary>The statement. Customer data: shown only in the local web UI, never sent to the hub.</summary>
public string? Text { get; init; }
public required long Executions { get; init; }
public required long WorkerTimeUs { get; init; }
public required long ElapsedTimeUs { get; init; }
public required long LogicalReads { get; init; }
public required long PhysicalReads { get; init; }
public required long LogicalWrites { get; init; }
}

View file

@ -61,7 +61,4 @@ public enum Capability
/// <summary>Granular server permissions such as <c>VIEW SERVER PERFORMANCE STATE</c>. SQL Server 2022+.</summary> /// <summary>Granular server permissions such as <c>VIEW SERVER PERFORMANCE STATE</c>. SQL Server 2022+.</summary>
GranularServerPermissions, GranularServerPermissions,
/// <summary><c>sys.dm_exec_text_query_plan</c> (cached plans as text, per statement). SQL Server 2005 SP2+.</summary>
TextQueryPlan,
} }

View file

@ -12,7 +12,6 @@ public sealed class InstanceCapabilities
[Capability.DynamicManagementViews] = new(9, 0, 0, 0), [Capability.DynamicManagementViews] = new(9, 0, 0, 0),
[Capability.DefaultTrace] = new(9, 0, 0, 0), [Capability.DefaultTrace] = new(9, 0, 0, 0),
[Capability.ViewServerStatePermission] = new(9, 0, 0, 0), [Capability.ViewServerStatePermission] = new(9, 0, 0, 0),
[Capability.TextQueryPlan] = new(9, 0, 3042, 0), // 2005 SP2
[Capability.ExtendedEvents] = new(10, 0, 0, 0), [Capability.ExtendedEvents] = new(10, 0, 0, 0),
[Capability.SystemHealthSession] = new(10, 0, 0, 0), [Capability.SystemHealthSession] = new(10, 0, 0, 0),
[Capability.SystemHealthFileTarget] = new(11, 0, 0, 0), [Capability.SystemHealthFileTarget] = new(11, 0, 0, 0),
@ -77,23 +76,6 @@ public sealed class InstanceCapabilities
return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList(); return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList();
} }
/// <summary>
/// Whether lacking the capability is a matter of what the instance is (its SQL Server version, or Azure managing the
/// infrastructure), so whatever needs it is not applicable there. Capabilities that depend on settings or rights
/// (Always On enabled …) are not: lacking them leaves something not measurable.
/// </summary>
public static bool IsInherent(Capability capability) =>
MinimumVersion.ContainsKey(capability) || capability is Capability.QueryStatsMemoryColumns or Capability.SelfManagedInfrastructure;
/// <summary>
/// "kræver Query Store (fra SQL Server 2016)" for the missing capabilities, for not-applicable reasons.
/// </summary>
public static string Describe(IEnumerable<Capability> missing)
{
ArgumentNullException.ThrowIfNull(missing);
return string.Join(", ", missing.Select(c => MinimumVersionFor(c) is { } v ? $"{c} (fra {v.Release.DisplayName()})" : c.ToString()));
}
/// <summary> /// <summary>
/// The minimum version for a version-gated capability, or <see langword="null"/> for runtime-only capabilities. /// The minimum version for a version-gated capability, or <see langword="null"/> for runtime-only capabilities.
/// </summary> /// </summary>

View file

@ -28,11 +28,11 @@
<td>@(c.Plan ?? "–")</td> <td>@(c.Plan ?? "–")</td>
<td class="nowrap @(c.LicenseExpires < Queries.Now.AddDays(30) ? "bad" : "")">@(c.LicenseExpires?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "–")</td> <td class="nowrap @(c.LicenseExpires < Queries.Now.AddDays(30) ? "bad" : "")">@(c.LicenseExpires?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "–")</td>
<td class="num">@c.Instances</td> <td class="num">@c.Instances</td>
<td><div class="counts"> <td class="counts">
@if (c.Critical > 0) { <Sev Severity="4" Count="c.Critical" /> } @if (c.Critical > 0) { <Sev Severity="4" Count="c.Critical" /> }
@if (c.High > 0) { <Sev Severity="3" Count="c.High" /> } @if (c.High > 0) { <Sev Severity="3" Count="c.High" /> }
@if (c.Critical == 0 && c.High == 0) { <span class="muted">ingen kritiske/høje</span> } @if (c.Critical == 0 && c.High == 0) { <span class="muted">ingen kritiske/høje</span> }
</div></td> </td>
<td class="nowrap @(c.LastSubmission < Queries.Now.AddHours(-1) ? "bad" : "")">@(c.LastSubmission?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "aldrig") UTC</td> <td class="nowrap @(c.LastSubmission < Queries.Now.AddHours(-1) ? "bad" : "")">@(c.LastSubmission?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "aldrig") UTC</td>
</tr> </tr>
} }

View file

@ -208,7 +208,7 @@ public static class ManagementReport
private static void Controls(Section section, ReportData data) private static void Controls(Section section, ReportData data)
{ {
section.AddParagraph("Kontroller", StyleNames.Heading1); section.AddParagraph("Kontroller", StyleNames.Heading1);
var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt. Instanser, hvor kontrollen ikke er relevant for SQL Server-versionen, tælles ikke med."); var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at versionen, rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt.");
intro.Style = "Muted"; intro.Style = "Muted";
var table = Table(section, ("Kontrol", 5.2), ("Opfyldt", 1.8), ("Ikke opfyldt", 2.1), ("Ikke målt", 1.9), ("Instanser, der ikke opfylder", 6)); var table = Table(section, ("Kontrol", 5.2), ("Opfyldt", 1.8), ("Ikke opfyldt", 2.1), ("Ikke målt", 1.9), ("Instanser, der ikke opfylder", 6));

View file

@ -79,12 +79,6 @@ public sealed record ComplianceControl(string Name, string Description, params s
return ControlStatus.NotMet; return ControlStatus.NotMet;
} }
// The core rule does not apply to this version (an integrity check that needs 2016 SP2 on 2005 …): neither met nor a gap.
if (relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.NotApplicable))
{
return ControlStatus.NotApplicable;
}
// The first rule is the core of the control; the others only count where they apply (an AG rule on an // The first rule is the core of the control; the others only count where they apply (an AG rule on an
// instance without availability groups, for example). // instance without availability groups, for example).
return relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.Passed) ? ControlStatus.Met : ControlStatus.NotMeasured; return relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.Passed) ? ControlStatus.Met : ControlStatus.NotMeasured;
@ -96,7 +90,4 @@ public enum ControlStatus
Met, Met,
NotMet, NotMet,
NotMeasured, NotMeasured,
/// <summary>The control does not apply to the instance's SQL Server version; counted neither as met nor as not measured.</summary>
NotApplicable,
} }

View file

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

View file

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

View file

@ -1,291 +0,0 @@
using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
/// <summary>
/// The time window analysis as PDF: load, waits against the normal level, top SQL, I/O, blocking, deadlocks and the
/// findings active in the window. Contains statement texts (customer data): it is produced locally and not sent anywhere.
/// </summary>
public static class TimeWindowReport
{
private const int MaxTextLength = 300;
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private static readonly Color Text = new(0x10, 0x18, 0x28);
private static readonly Color Muted = new(0x66, 0x70, 0x85);
private static readonly Color Border = new(0xE4, 0xE7, 0xEC);
private static readonly Color Surface = new(0xF5, 0xF7, 0xFA);
private static readonly Color Brand = new(0x0E, 0x74, 0x90);
private static readonly Color Unusual = new(0xD9, 0x2D, 0x20);
public static byte[] Render(TimeWindowAnalysis analysis, string organization, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(analysis);
ArgumentNullException.ThrowIfNull(zone);
InterFontResolver.Install();
var document = new Document();
document.Info.Title = $"Analyse af {analysis.Instance.Name} {Time(analysis.From, zone)} – {Time(analysis.To, zone)}";
document.Info.Author = "SQLInsight Pro";
Styles(document);
var section = document.AddSection();
section.PageSetup = document.DefaultPageSetup.Clone();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.TopMargin = Unit.FromCentimeter(2);
section.PageSetup.BottomMargin = Unit.FromCentimeter(2);
section.PageSetup.LeftMargin = Unit.FromCentimeter(1.8);
section.PageSetup.RightMargin = Unit.FromCentimeter(1.8);
var header = section.Headers.Primary.AddParagraph($"SQLInsight Pro · {organization}");
header.Style = "Muted";
var footer = section.Footers.Primary.AddParagraph($"Analyse af {analysis.Instance.Name} Side ");
footer.Style = "Muted";
footer.AddPageField();
footer.AddText(" af ");
footer.AddNumPagesField();
var title = section.AddParagraph($"Analyse af {analysis.Instance.Name}");
title.Format.Font.Size = 20;
title.Format.Font.Bold = true;
var period = section.AddParagraph($"{Time(analysis.From, zone)} – {Time(analysis.To, zone)} · {ResolutionText(analysis)}");
period.Format.Font.Color = Brand;
section.AddParagraph($"Sammenlignet med samme klokkeslæt og ugedag de {WindowBaseline.Weeks} foregående uger. Røde værdier afviger tydeligt fra det normale.", "Muted");
if (!analysis.HasData)
{
section.AddParagraph(NoDataText(analysis));
return Save(document);
}
Metrics(section, analysis);
Waits(section, analysis);
TopQueries(section, analysis);
Files(section, analysis);
Blocking(section, analysis, zone);
Deadlocks(section, analysis, zone);
Findings(section, analysis);
return Save(document);
}
/// <summary>Why a window has no data; shared with the web page.</summary>
public static string NoDataText(TimeWindowAnalysis analysis)
{
ArgumentNullException.ThrowIfNull(analysis);
return "Der er ingen målinger i dette tidsrum. Data findes kun fra SQLInsight Pro blev installeret og inden for opbevaringstiden: "
+ $"målinger hvert 5. minut i {analysis.RawDays} dage, timeværdier i 400 dage og blocking og deadlocks i 90 dage (standard; se SQLInsight:Retention).";
}
public static string ResolutionText(TimeWindowAnalysis analysis)
{
ArgumentNullException.ThrowIfNull(analysis);
return analysis.Resolution == WindowResolution.Raw
? "målinger hvert 5. minut"
: $"timeværdier (tidsrummet ligger før de seneste {analysis.RawDays} dage, så detaljerne er slettet)";
}
private static void Metrics(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("Belastning", StyleNames.Heading1);
var table = Table(section, [6.5, 2.5, 2.5, 2.5, 3.4]);
Header(table, "Måling", "Gns.", "Maks.", "Normalt", "");
foreach (var m in analysis.Metrics.Where(m => m.Points.Count > 0))
{
Row(table, m.IsUnusual, m.Label, Number(m.Average, m.Decimals) + m.Unit, Number(m.Peak, m.Decimals) + m.Unit,
m.NormalAverage is { } n ? Number(n, m.Decimals) + m.Unit : "–", m.IsUnusual ? "afviger" : "");
}
}
private static void Waits(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("Største ventetider", StyleNames.Heading1);
if (analysis.Waits.Count == 0)
{
section.AddParagraph("Ingen ventetider målt i tidsrummet.", "Muted");
return;
}
var table = Table(section, [6.5, 2.6, 2.6, 2.6, 3.1]);
Header(table, "Wait type", "Ventetid", "Pr. minut", "Normalt", "Andel");
foreach (var w in analysis.Waits.Take(15))
{
Row(table, w.IsUnusual, w.WaitType, Number(w.WaitTimeMs / 1000.0, 1) + " s", Number(w.PerMinute / 1000.0, 2) + " s",
w.NormalPerMinute is { } n ? Number(n / 1000.0, 2) + " s" : "–", Number(100 * w.Share, 0) + " %");
}
}
private static void TopQueries(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("Tungeste forespørgsler", StyleNames.Heading1);
if (analysis.TopQueries.Count == 0)
{
section.AddParagraph("Ingen forespørgselsstatistik i tidsrummet (indsamles fra version 1.0.3 og gemmes som de øvrige 5-minutters målinger).", "Muted");
return;
}
var table = Table(section, [9.3, 2, 2, 2.2, 1.9]);
Header(table, "Forespørgsel", "CPU", "Varighed", "Læsninger", "Kørsler");
foreach (var q in analysis.TopQueries.Take(15))
{
var row = table.AddRow();
var text = q.Text is { Length: > MaxTextLength } t ? t[..MaxTextLength] + " …" : q.Text ?? "(teksten er ikke længere i plan cache)";
var cell = row.Cells[0].AddParagraph(q.DatabaseName ?? "");
cell.Style = "Muted";
var code = row.Cells[0].AddParagraph(text);
code.Format.Font.Size = 7;
row.Cells[1].AddParagraph(Number(q.WorkerTimeUs / 1_000_000.0, 1) + " s");
row.Cells[2].AddParagraph(Number(q.ElapsedTimeUs / 1_000_000.0, 1) + " s");
row.Cells[3].AddParagraph(Number(q.LogicalReads, 0));
row.Cells[4].AddParagraph(Number(q.Executions, 0));
}
}
private static void Files(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("I/O-latens pr. fil", StyleNames.Heading1);
if (analysis.Files.Count == 0)
{
section.AddParagraph("Ingen I/O-målinger i tidsrummet.", "Muted");
return;
}
var table = Table(section, [6.5, 1.5, 2.6, 2.2, 2.6, 2]);
Header(table, "Database", "Type", "Læsninger", "Gns. læs", "Skrivninger", "Gns. skriv");
foreach (var f in analysis.Files.Take(15))
{
var slow = f.AverageReadMs >= 20 || f.AverageWriteMs >= (f.FileType == "LOG" ? 5 : 20);
Row(table, slow, $"{f.DatabaseName} ({f.FileId})", f.FileType, Number(f.Reads, 0), Ms(f.AverageReadMs), Number(f.Writes, 0), Ms(f.AverageWriteMs));
}
}
private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
{
section.AddParagraph("Blocking", StyleNames.Heading1);
if (analysis.Blocking.Count == 0)
{
section.AddParagraph("Ingen blocking over 5 sekunder i tidsrummet.", "Muted");
return;
}
var table = Table(section, [3.5, 6.4, 2.7, 2.6, 2.2]);
Header(table, "Tidspunkt", "Head blocker", "Blokerede", "Længste", "");
foreach (var b in analysis.Blocking.Take(25))
{
var head = b.Sessions.Count > 0 ? b.Sessions[0] : null;
Row(table, false, Time(new DateTimeOffset(DateTime.SpecifyKind(b.SampledAt, DateTimeKind.Utc)), zone),
$"session {b.HeadSessionId} {head?.ProgramName} {head?.LoginName}".Trim(), b.BlockedCount.ToString(Danish), Number(b.MaxWaitMs / 1000.0, 0) + " s", "");
}
}
private static void Deadlocks(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
{
section.AddParagraph("Deadlocks", StyleNames.Heading1);
if (analysis.Deadlocks.Count == 0)
{
section.AddParagraph("Ingen deadlocks i tidsrummet.", "Muted");
return;
}
var table = Table(section, [3.5, 13.9]);
Header(table, "Tidspunkt", "Offer");
foreach (var d in analysis.Deadlocks.Take(25))
{
var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
Row(table, false, Time(new DateTimeOffset(DateTime.SpecifyKind(d.OccurredAt, DateTimeKind.Utc)), zone), $"session {victim?.SessionId} {victim?.ClientApp}".Trim());
}
}
private static void Findings(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("Fund, der var aktive i tidsrummet", StyleNames.Heading1);
if (analysis.Findings.Count == 0)
{
section.AddParagraph("Ingen.", "Muted");
return;
}
var table = Table(section, [2.4, 12.6, 2.4]);
Header(table, "Alvor", "Fund", "Regel");
foreach (var f in analysis.Findings.Take(40))
{
Row(table, false, f.Severity.ToString(), f.Title, f.RuleId);
}
}
private static void Styles(Document document)
{
var normal = document.Styles[StyleNames.Normal]!;
normal.Font.Name = InterFontResolver.Family;
normal.Font.Size = 9;
normal.Font.Color = Text;
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(3);
var heading = document.Styles[StyleNames.Heading1]!;
heading.Font.Size = 13;
heading.Font.Bold = true;
heading.ParagraphFormat.SpaceBefore = Unit.FromPoint(14);
heading.ParagraphFormat.SpaceAfter = Unit.FromPoint(5);
heading.ParagraphFormat.KeepWithNext = true;
var muted = document.Styles.AddStyle("Muted", StyleNames.Normal);
muted.Font.Color = Muted;
muted.Font.Size = 8;
}
private static Table Table(Section section, double[] widthsCm)
{
var table = section.AddTable();
table.Borders.Bottom = new Border { Width = 0.5, Color = Border };
foreach (var width in widthsCm)
{
table.AddColumn(Unit.FromCentimeter(width));
}
return table;
}
private static void Header(Table table, params string[] titles)
{
var row = table.AddRow();
row.HeadingFormat = true;
row.Shading.Color = Surface;
row.Format.Font.Bold = true;
for (var i = 0; i < titles.Length; i++)
{
row.Cells[i].AddParagraph(titles[i]);
}
}
private static void Row(Table table, bool unusual, params string[] values)
{
var row = table.AddRow();
row.Borders.Bottom = new Border { Width = 0.25, Color = Border };
for (var i = 0; i < values.Length; i++)
{
var paragraph = row.Cells[i].AddParagraph(values[i]);
if (unusual)
{
paragraph.Format.Font.Color = Unusual;
}
}
}
private static string Time(DateTimeOffset utc, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(utc, zone).ToString("dd-MM-yyyy HH:mm", Danish);
private static string Number(double? value, int decimals) => value is { } v ? v.ToString("N" + decimals, Danish) : "–";
private static string Ms(double? value) => value is { } v ? v.ToString("N1", Danish) + " ms" : "–";
private static byte[] Save(Document document)
{
var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument();
using var output = new MemoryStream();
renderer.PdfDocument.Save(output, false);
return output.ToArray();
}
}

View file

@ -1,189 +0,0 @@
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>A statement's use summed over a time window. <see cref="Text"/> is customer data (local UI only).</summary>
public sealed record TopQuery
{
public required string QueryKey { get; init; }
public string? DatabaseName { get; init; }
public string? Text { get; init; }
public long Executions { get; init; }
public long WorkerTimeUs { get; init; }
public long ElapsedTimeUs { get; init; }
public long LogicalReads { get; init; }
public long PhysicalReads { get; init; }
public long LogicalWrites { get; init; }
}
/// <summary>What a top-SQL list is sorted by.</summary>
public enum TopQueryOrder
{
Cpu,
Duration,
Reads,
Executions,
}
/// <summary>Wait time summed over a span of whole hours (hourly history).</summary>
public sealed record HourlyWaitTotal(string WaitType, long WaitTimeMs, long WaitingTasks, int Hours);
public sealed partial class InsightRepository
{
/// <summary>At most this many statements are stored per collection: the heaviest by CPU.</summary>
public const int MaxQueriesPerSample = 100;
private static async Task SaveQueryStatsAsync(SqlConnection connection, int instanceId, QueryStatsDelta delta)
{
var queries = delta.Queries.Take(MaxQueriesPerSample).ToList();
if (queries.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
INSERT insight.QueryStatSamples (InstanceId, SampledAt, IntervalSeconds, QueryKey, Executions, WorkerTimeUs, ElapsedTimeUs, LogicalReads, PhysicalReads, LogicalWrites)
SELECT @instanceId, @at, @interval, j.[Key], j.Executions, j.WorkerTimeUs, j.ElapsedTimeUs, j.LogicalReads, j.PhysicalReads, j.LogicalWrites
FROM OPENJSON(@json) WITH ([Key] varchar(200), Executions bigint, WorkerTimeUs bigint, ElapsedTimeUs bigint,
LogicalReads bigint, PhysicalReads bigint, LogicalWrites bigint) j
WHERE NOT EXISTS (SELECT 1 FROM insight.QueryStatSamples s WHERE s.InstanceId = @instanceId AND s.SampledAt = @at AND s.QueryKey = j.[Key]);
MERGE insight.QueryTexts AS t
USING (SELECT [Key], MAX(DatabaseName) AS DatabaseName, MAX([Text]) AS [Text]
FROM OPENJSON(@json) WITH ([Key] varchar(200), DatabaseName nvarchar(128), [Text] nvarchar(4000)) GROUP BY [Key]) AS s
ON t.InstanceId = @instanceId AND t.QueryKey = s.[Key]
WHEN MATCHED THEN UPDATE SET LastSeenAt = @at, QueryText = COALESCE(t.QueryText, s.[Text]), DatabaseName = COALESCE(t.DatabaseName, s.DatabaseName)
WHEN NOT MATCHED THEN INSERT (InstanceId, QueryKey, DatabaseName, QueryText, FirstSeenAt, LastSeenAt) VALUES (@instanceId, s.[Key], s.DatabaseName, s.[Text], @at, @at);
""", new
{
instanceId,
at = Utc(delta.To),
interval = (int)(delta.To - delta.From).TotalSeconds,
json = JsonSerializer.Serialize(queries, Json),
}).ConfigureAwait(false);
}
/// <summary>The statements that used the most in [from, to), with their text.</summary>
public async Task<IReadOnlyList<TopQuery>> GetTopQueriesAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, TopQueryOrder order = TopQueryOrder.Cpu, int top = 25, CancellationToken cancellationToken = default)
{
var orderBy = order switch
{
TopQueryOrder.Duration => "SUM(s.ElapsedTimeUs)",
TopQueryOrder.Reads => "SUM(s.LogicalReads)",
TopQueryOrder.Executions => "SUM(s.Executions)",
_ => "SUM(s.WorkerTimeUs)",
};
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<TopQuery>($"""
SELECT TOP (@top) s.QueryKey, MAX(t.DatabaseName) AS DatabaseName, MAX(t.QueryText) AS [Text],
SUM(s.Executions) AS Executions, SUM(s.WorkerTimeUs) AS WorkerTimeUs, SUM(s.ElapsedTimeUs) AS ElapsedTimeUs,
SUM(s.LogicalReads) AS LogicalReads, SUM(s.PhysicalReads) AS PhysicalReads, SUM(s.LogicalWrites) AS LogicalWrites
FROM insight.QueryStatSamples s
LEFT JOIN insight.QueryTexts t ON t.InstanceId = s.InstanceId AND t.QueryKey = s.QueryKey
WHERE s.InstanceId = @instanceId AND s.SampledAt > @from AND s.SampledAt <= @to
GROUP BY s.QueryKey
ORDER BY {orderBy} DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>Raw 5-minute samples of a metric in [from, to].</summary>
public async Task<IReadOnlyList<MetricPoint>> GetMetricSamplesAsync(int instanceId, string metric, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<MetricPoint>(
"SELECT SampledAt AS At, Value FROM insight.MetricSamples WHERE InstanceId = @instanceId AND Metric = @metric AND SampledAt >= @from AND SampledAt <= @to ORDER BY SampledAt;",
new { instanceId, metric, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>Hourly averages of a metric for the hours overlapping [from, to).</summary>
public async Task<IReadOnlyList<MetricPoint>> GetMetricHourlyAsync(int instanceId, string metric, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<MetricPoint>(
"SELECT [Hour] AS At, AvgValue AS Value FROM insight.MetricHourly WHERE InstanceId = @instanceId AND Metric = @metric AND [Hour] >= @from AND [Hour] < @to ORDER BY [Hour];",
new { instanceId, metric, from = FloorHour(from), to = to.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>Waits from the 5-minute samples taken in (from, to].</summary>
public async Task<IReadOnlyList<WaitTotal>> GetWaitsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<WaitTotal>("""
SELECT WaitType, SUM(WaitingTasks) AS WaitingTasks, SUM(WaitTimeMs) AS WaitTimeMs, SUM(SignalWaitTimeMs) AS SignalWaitTimeMs
FROM insight.WaitSamples WHERE InstanceId = @instanceId AND SampledAt > @from AND SampledAt <= @to
GROUP BY WaitType ORDER BY SUM(WaitTimeMs) DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>Waits from the hourly history for the hours overlapping [from, to), with how many hours had data.</summary>
public async Task<IReadOnlyList<HourlyWaitTotal>> GetWaitsHourlyAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<HourlyWaitTotal>("""
SELECT w.WaitType, SUM(w.WaitTimeMs) AS WaitTimeMs, SUM(w.WaitingTasks) AS WaitingTasks,
(SELECT COUNT(DISTINCT h.[Hour]) FROM insight.WaitHourly h WHERE h.InstanceId = @instanceId AND h.[Hour] >= @from AND h.[Hour] < @to) AS Hours
FROM insight.WaitHourly w WHERE w.InstanceId = @instanceId AND w.[Hour] >= @from AND w.[Hour] < @to
GROUP BY w.WaitType;
""", new { instanceId, from = FloorHour(from), to = to.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
public async Task<IReadOnlyList<FileLatency>> GetFileLatencyAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<FileLatency>("""
SELECT DatabaseName, FileId, MAX(FileType) AS FileType, SUM(Reads) AS Reads, SUM(ReadStallMs) AS ReadStallMs, SUM(Writes) AS Writes, SUM(WriteStallMs) AS WriteStallMs
FROM insight.FileIoSamples WHERE InstanceId = @instanceId AND SampledAt > @from AND SampledAt <= @to
GROUP BY DatabaseName, FileId
ORDER BY SUM(ReadStallMs) + SUM(WriteStallMs) DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
public async Task<IReadOnlyList<StoredBlocking>> GetBlockingAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, string SessionsJson)>("""
SELECT TOP (200) SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson FROM insight.BlockingEvents
WHERE InstanceId = @instanceId AND SampledAt >= @from AND SampledAt <= @to ORDER BY SampledAt;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs,
JsonSerializer.Deserialize<List<SessionInfo>>(r.SessionsJson, Json) ?? [])).ToList();
}
public async Task<IReadOnlyList<StoredDeadlock>> GetDeadlocksAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)>("""
SELECT TOP (200) DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks
WHERE InstanceId = @instanceId AND OccurredAt >= @from AND OccurredAt <= @to ORDER BY OccurredAt;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.Select(r => new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize<List<DeadlockProcess>>(r.ProcessesJson, Json) ?? [], r.GraphXml)).ToList();
}
/// <summary>Findings that were active at some point in [from, to] (first seen before the end, not resolved before the start).</summary>
public async Task<IReadOnlyList<StoredFinding>> GetFindingsActiveDuringAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<FindingRow>($"""
SELECT TOP (200) {FindingColumns}
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
WHERE f.InstanceId = @instanceId AND f.FirstSeenAt <= @to AND (f.ResolvedAt IS NULL OR f.ResolvedAt >= @from)
ORDER BY f.Severity DESC, f.FirstSeenAt;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
private static DateTime FloorHour(DateTimeOffset value)
{
var utc = value.UtcDateTime;
return new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);
}
}

View file

@ -63,8 +63,6 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.MetricSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.MetricSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);

View file

@ -198,12 +198,6 @@ public sealed partial class InsightRepository(string connectionString)
case DeadlockSnapshot deadlocks: case DeadlockSnapshot deadlocks:
await SaveDeadlocksAsync(connection, instanceId, deadlocks).ConfigureAwait(false); await SaveDeadlocksAsync(connection, instanceId, deadlocks).ConfigureAwait(false);
return; return;
case CumulativeSample<QueryStatsSnapshot, QueryStatsDelta> { Delta: { } queries }:
await SaveQueryStatsAsync(connection, instanceId, queries).ConfigureAwait(false);
return;
case CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>:
// Never as a JSON snapshot: it holds statement texts.
return;
default: default:
await connection.ExecuteAsync(""" await connection.ExecuteAsync("""
MERGE insight.Snapshots AS t MERGE insight.Snapshots AS t

View file

@ -1,28 +0,0 @@
/* Top SQL: what the heaviest statements used per collection (deltas of sys.dm_exec_query_stats), for the time window
analysis. Kept as long as the other raw samples (Retention:RawDays). */
CREATE TABLE insight.QueryStatSamples
(
InstanceId int NOT NULL,
SampledAt datetime2(0) NOT NULL,
IntervalSeconds int NOT NULL,
QueryKey varchar(200) NOT NULL,
Executions bigint NOT NULL,
WorkerTimeUs bigint NOT NULL,
ElapsedTimeUs bigint NOT NULL,
LogicalReads bigint NOT NULL,
PhysicalReads bigint NOT NULL,
LogicalWrites bigint NOT NULL,
CONSTRAINT PK_QueryStatSamples PRIMARY KEY (InstanceId, SampledAt, QueryKey)
);
/* The statement text once per statement. Customer data: shown in the local web UI only, never sent to the hub. */
CREATE TABLE insight.QueryTexts
(
InstanceId int NOT NULL,
QueryKey varchar(200) NOT NULL,
DatabaseName nvarchar(128) NULL,
QueryText nvarchar(4000) NULL,
FirstSeenAt datetime2(0) NOT NULL,
LastSeenAt datetime2(0) NOT NULL,
CONSTRAINT PK_QueryTexts PRIMARY KEY (InstanceId, QueryKey)
);

View file

@ -20,7 +20,7 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
if (context.Instance.Version < Definition.MinimumSqlVersion) if (context.Instance.Version < Definition.MinimumSqlVersion)
{ {
return RuleEvaluation.NotApplicable($"Ikke relevant for {context.Instance.Release.DisplayName()}: reglen kræver SQL Server {Definition.MinimumVersion} eller nyere."); return RuleEvaluation.NotMeasurable($"Reglen kræver SQL Server {Definition.MinimumVersion} eller nyere.");
} }
var results = context.Data.Get<CustomQueryResults>(); var results = context.Data.Get<CustomQueryResults>();

View file

@ -67,18 +67,15 @@ public sealed class RuleEngine
var missing = context.Capabilities.Missing(rule.RequiredCapabilities); var missing = context.Capabilities.Missing(rule.RequiredCapabilities);
if (missing.Count > 0) if (missing.Count > 0)
{ {
// Missing because of what the instance is (its version, or Azure) is not a gap in the monitoring.
var inherent = missing.All(InstanceCapabilities.IsInherent);
var release = context.Instance.Release.DisplayName();
return new RuleResult return new RuleResult
{ {
RuleId = rule.Id, RuleId = rule.Id,
RuleTitle = rule.Title, RuleTitle = rule.Title,
Outcome = inherent ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable, Outcome = RuleOutcome.NotMeasurable,
MissingCapabilities = missing, MissingCapabilities = missing,
Reason = missing.Contains(Capability.SelfManagedInfrastructure) ? $"Håndteres af Azure på {release}." Reason = missing.Contains(Capability.SelfManagedInfrastructure)
: inherent ? $"Ikke relevant for {release}: kræver {InstanceCapabilities.Describe(missing)}." ? $"Håndteres af Azure på {context.Instance.Release.DisplayName()}."
: $"Ikke målbar på {release}: kræver {string.Join(", ", missing)}.", : $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.",
}; };
} }
@ -100,7 +97,7 @@ public sealed class RuleEngine
{ {
RuleId = rule.Id, RuleId = rule.Id,
RuleTitle = rule.Title, RuleTitle = rule.Title,
Outcome = ex.NotApplicable ? RuleOutcome.NotApplicable : RuleOutcome.NotMeasurable, Outcome = RuleOutcome.NotMeasurable,
Reason = ex.Message, Reason = ex.Message,
}; };
} }

View file

@ -34,8 +34,6 @@ public sealed record RuleEvaluation
} }
public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason); public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason);
public static RuleEvaluation NotApplicable(string reason) => new(RuleOutcome.NotApplicable, [], reason);
} }
public enum RuleOutcome public enum RuleOutcome
@ -51,11 +49,4 @@ public enum RuleOutcome
/// <summary>The rule failed while running. This is never the same as <see cref="Passed"/>.</summary> /// <summary>The rule failed while running. This is never the same as <see cref="Passed"/>.</summary>
Error, Error,
/// <summary>
/// The rule does not apply to this kind of instance: its SQL Server version lacks what the rule looks at (Query
/// Store on 2005 …), or Azure manages it. Not a gap in the monitoring, so not counted as not measurable.
/// Last in the enum: outcomes are stored as numbers.
/// </summary>
NotApplicable,
} }

View file

@ -13,7 +13,7 @@ public sealed record RuleResult
public required RuleOutcome Outcome { get; init; } public required RuleOutcome Outcome { get; init; }
public IReadOnlyList<Finding> Findings { get; init; } = []; public IReadOnlyList<Finding> Findings { get; init; } = [];
/// <summary>Capabilities the instance lacks, when the outcome is <see cref="RuleOutcome.NotApplicable"/> or <see cref="RuleOutcome.NotMeasurable"/> for that reason.</summary> /// <summary>Capabilities the instance lacks, when the outcome is <see cref="RuleOutcome.NotMeasurable"/> for that reason.</summary>
public IReadOnlyList<Capability> MissingCapabilities { get; init; } = []; public IReadOnlyList<Capability> MissingCapabilities { get; init; } = [];
/// <summary>Why the rule was not measurable or failed.</summary> /// <summary>Why the rule was not measurable or failed.</summary>

View file

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

View file

@ -45,12 +45,12 @@ else
</td> </td>
<td><a href="/instanser/@Uri.EscapeDataString(i.Name)"><strong>@i.Name</strong></a><div class="muted">@i.ServerName</div></td> <td><a href="/instanser/@Uri.EscapeDataString(i.Name)"><strong>@i.Name</strong></a><div class="muted">@i.ServerName</div></td>
<td>@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion, i.EngineEdition))<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel</div></td> <td>@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion, i.EngineEdition))<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel</div></td>
<td><div class="counts"> <td class="counts">
@if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> } @if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> }
@if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> } @if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> }
@if (i.Medium > 0) { <SeverityBadge Severity="Severity.Medium" Count="i.Medium" /> } @if (i.Medium > 0) { <SeverityBadge Severity="Severity.Medium" Count="i.Medium" /> }
@if (i.Low > 0) { <SeverityBadge Severity="Severity.Low" Count="i.Low" /> } @if (i.Low > 0) { <SeverityBadge Severity="Severity.Low" Count="i.Low" /> }
</div></td> </td>
<td>@i.NotMeasurable</td> <td>@i.NotMeasurable</td>
<td title="@Display.Time(i.LastSeenAt)">@Display.Ago(i.LastSeenAt)</td> <td title="@Display.Time(i.LastSeenAt)">@Display.Ago(i.LastSeenAt)</td>
</tr> </tr>

View file

@ -36,10 +36,7 @@
<FindingList Findings="_findings" Empty="Ingen aktive fund." /> <FindingList Findings="_findings" Empty="Ingen aktive fund." />
</section> </section>
<div class="section-head"> <h2 class="section">Belastning, seneste 24 timer</h2>
<h2 class="section">Belastning, seneste 24 timer</h2>
<a class="button" href="@AnalysisUrl(null)">Analysér et tidsrum</a>
</div>
<div class="grid2"> <div class="grid2">
<div class="panel"><Sparkline Points="_cpuSql" Label="SQL Server CPU" Unit=" %" Max="100" /></div> <div class="panel"><Sparkline Points="_cpuSql" Label="SQL Server CPU" Unit=" %" Max="100" /></div>
<div class="panel"><Sparkline Points="_cpuOther" Label="CPU brugt af andre processer" Unit=" %" Max="100" Empty="kan ikke måles på Linux, eller endnu ikke nok målinger." /></div> <div class="panel"><Sparkline Points="_cpuOther" Label="CPU brugt af andre processer" Unit=" %" Max="100" Empty="kan ikke måles på Linux, eller endnu ikke nok målinger." /></div>
@ -119,7 +116,7 @@ else
{ {
var head = b.Sessions.FirstOrDefault(); var head = b.Sessions.FirstOrDefault();
<tr> <tr>
<td><a href="@AnalysisUrl(b.SampledAt)" title="Analysér tidsrummet omkring">@Display.Time(b.SampledAt)</a></td> <td>@Display.Time(b.SampledAt)</td>
<td>@b.HeadSessionId <span class="muted">@head?.Status @head?.ProgramName</span></td> <td>@b.HeadSessionId <span class="muted">@head?.Status @head?.ProgramName</span></td>
<td class="num">@b.BlockedCount</td> <td class="num">@b.BlockedCount</td>
<td class="num">@Display.Number(b.MaxWaitMs / 1000.0) s</td> <td class="num">@Display.Number(b.MaxWaitMs / 1000.0) s</td>
@ -144,7 +141,7 @@ else
{ {
var victim = d.Processes.FirstOrDefault(p => p.IsVictim); var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
<tr> <tr>
<td><a href="@AnalysisUrl(d.OccurredAt)" title="Analysér tidsrummet omkring">@Display.Time(d.OccurredAt)</a></td> <td>@Display.Time(d.OccurredAt)</td>
<td>session @victim?.SessionId <span class="muted">@victim?.ClientApp</span></td> <td>session @victim?.SessionId <span class="muted">@victim?.ClientApp</span></td>
<td><a href="/deadlocks/@(d.DeadlockId).xdl">.xdl</a></td> <td><a href="/deadlocks/@(d.DeadlockId).xdl">.xdl</a></td>
</tr> </tr>
@ -159,7 +156,7 @@ else
<div class="card-head"><h2>Ikke målbart på denne instans</h2></div> <div class="card-head"><h2>Ikke målbart på denne instans</h2></div>
@if (_notMeasurable.Count == 0) @if (_notMeasurable.Count == 0)
{ {
<p class="muted">Alle regler, der er relevante for instansen, kunne evalueres.</p> <p class="muted">Alle regler kunne evalueres.</p>
} }
else else
{ {
@ -174,22 +171,6 @@ else
</tbody> </tbody>
</table> </table>
} }
@if (_notApplicable.Count > 0)
{
<details class="not-applicable">
<summary>Ikke relevant for @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_notApplicable.Count)</summary>
<p class="muted">Regler om noget, denne SQL Server-version ikke har (fx Query Store eller system_health). Det er ikke et hul i overvågningen.</p>
<table>
<thead><tr><th>Regel</th><th></th><th>Hvorfor</th></tr></thead>
<tbody>
@foreach (var r in _notApplicable)
{
<tr><td class="mono">@r.RuleId</td><td>@r.RuleTitle</td><td>@r.Reason</td></tr>
}
</tbody>
</table>
</details>
}
</section> </section>
<section class="card"> <section class="card">
@ -219,7 +200,6 @@ else
private IReadOnlyList<StoredFinding> _findings = []; private IReadOnlyList<StoredFinding> _findings = [];
private IReadOnlyList<StoredFinding> _resolved = []; private IReadOnlyList<StoredFinding> _resolved = [];
private IReadOnlyList<StoredRuleState> _notMeasurable = []; private IReadOnlyList<StoredRuleState> _notMeasurable = [];
private IReadOnlyList<StoredRuleState> _notApplicable = [];
private IReadOnlyList<MetricPoint> _cpuSql = [], _cpuOther = [], _batches = [], _ple = []; private IReadOnlyList<MetricPoint> _cpuSql = [], _cpuOther = [], _batches = [], _ple = [];
private IReadOnlyList<WaitTotal> _waits = []; private IReadOnlyList<WaitTotal> _waits = [];
private IReadOnlyList<FileLatency> _files = []; private IReadOnlyList<FileLatency> _files = [];
@ -238,9 +218,7 @@ else
var day = DateTimeOffset.UtcNow.AddDays(-1); var day = DateTimeOffset.UtcNow.AddDays(-1);
_findings = await Repository.GetActiveFindingsAsync(id); _findings = await Repository.GetActiveFindingsAsync(id);
_resolved = await Repository.GetRecentlyResolvedAsync(id, 30); _resolved = await Repository.GetRecentlyResolvedAsync(id, 30);
var states = await Repository.GetRuleStatesAsync(id); _notMeasurable = (await Repository.GetRuleStatesAsync(id)).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();
_cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day); _cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day);
_cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day); _cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day);
_batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day); _batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day);
@ -253,19 +231,5 @@ else
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–"; private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
/// <summary>The time window analysis: the last hour, or half an hour on each side of an event.</summary>
private string AnalysisUrl(DateTime? around)
{
var url = "/instanser/" + Uri.EscapeDataString(_instance!.Name) + "/analyse";
if (around is not { } at)
{
return url;
}
var (from, to) = SQLInsight.Reports.TimeWindow.Around(at);
return url + "?fra=" + Uri.EscapeDataString(SQLInsight.Reports.TimeWindow.ToInput(from, Display.Zone))
+ "&til=" + Uri.EscapeDataString(SQLInsight.Reports.TimeWindow.ToInput(to, Display.Zone));
}
private static string LatencyClass(double? value, double threshold) => value >= threshold ? "bad" : string.Empty; private static string LatencyClass(double? value, double threshold) => value >= threshold ? "bad" : string.Empty;
} }

View file

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

View file

@ -112,27 +112,6 @@ app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository r
var data = await SQLInsight.Reports.ReportData.LoadAsync(repository, settings.Organization, now.AddDays(-days), now, zone, TimeProvider.System, ct); var data = await SQLInsight.Reports.ReportData.LoadAsync(repository, settings.Organization, now.AddDays(-days), now, zone, TimeProvider.System, ct);
return Results.File(SQLInsight.Reports.ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf"); return Results.File(SQLInsight.Reports.ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf");
}); });
// Time window analysis as PDF. Contains statement texts: produced here, for the person who is logged in.
app.MapGet("/analyse.pdf", async (string? instans, string? fra, string? til, string? sortering, InsightRepository repository, Display display,
Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
{
if (instans is null || await repository.GetInstanceAsync(instans, ct) is not { } instance)
{
return Results.NotFound();
}
if (!SQLInsight.Reports.TimeWindow.TryParse(fra, til, display.Zone, out var from, out var to, out var error))
{
return Results.BadRequest(error);
}
var now = DateTimeOffset.UtcNow;
var analysis = await SQLInsight.Reports.TimeWindowAnalysis.LoadAsync(repository, instance, from, to, now,
new RetentionOptions().RawDays, SQLInsight.Reports.AnalysisOrder.Parse(sortering), ct);
var file = $"analyse-{string.Concat(instance.Name.Select(c => char.IsLetterOrDigit(c) || c == '-' ? c : '_'))}-{SQLInsight.Reports.TimeWindow.ToInput(from, display.Zone).Replace(':', '-')}.pdf";
return Results.File(SQLInsight.Reports.TimeWindowReport.Render(analysis, options.Value.Organization, display.Zone), "application/pdf", file);
});
app.MapRazorComponents<App>(); app.MapRazorComponents<App>();
// The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader. // The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader.

View file

@ -175,21 +175,3 @@ a.button:hover { filter: brightness(1.08); }
.context-menu { position: absolute; z-index: 1000; min-width: 12rem; padding: .3rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); } .context-menu { position: absolute; z-index: 1000; min-width: 12rem; padding: .3rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); }
.context-menu a { display: block; padding: .45rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; } .context-menu a { display: block; padding: .45rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; }
.context-menu a:hover, .context-menu a:focus { background: var(--surface-2); outline: none; } .context-menu a:hover, .context-menu a:focus { background: var(--surface-2); outline: none; }
/* Time window analysis */
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-top: 1.75rem; }
.section-head h2.section { margin-top: 0; }
.window-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem 1rem; }
.window-form label { display: grid; gap: .25rem; font-size: .85rem; color: var(--text-2); }
.window-form input, .window-form select { font: inherit; padding: .4rem .5rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); }
.window-form button.button { border: 0; cursor: pointer; font: inherit; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
.window-form .presets { display: flex; gap: .75rem; font-size: .9rem; }
.small { font-size: .8rem; }
.panel.unusual { border-left: 4px solid var(--critical); }
tr.unusual td { background: var(--critical-soft); }
pre.sql { white-space: pre-wrap; word-break: break-word; max-height: 9rem; overflow: auto; margin: .25rem 0 0; font-size: .8rem; }
/* Rules that do not apply to the instance's version: collapsed, out of the way of real gaps */
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; }

View file

@ -1,28 +0,0 @@
using SQLInsight.Reports;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Agent.Tests;
public class ComplianceControlTests
{
private static readonly ComplianceControl Integrity = ComplianceControl.All.Single(c => c.Name == "Integritetstjek");
[Fact]
public void Control_whose_core_rule_does_not_apply_to_the_version_is_not_counted_as_not_measured()
{
var states = new[]
{
new InstanceRuleState("KGAA-S05", "SQLI-DB-009", RuleOutcome.NotApplicable, "Ikke relevant for SQL Server 2005"),
new InstanceRuleState("KGAA-S05", "SQLI-DB-010", RuleOutcome.Passed, null),
new InstanceRuleState("SQL01", "SQLI-DB-009", RuleOutcome.NotMeasurable, "Adgang nægtet"),
new InstanceRuleState("SQL02", "SQLI-DB-009", RuleOutcome.Passed, null),
new InstanceRuleState("SQL03", "SQLI-DB-010", RuleOutcome.Findings, null),
};
Assert.Equal(ControlStatus.NotApplicable, Integrity.StatusOn("KGAA-S05", states));
Assert.Equal(ControlStatus.NotMeasured, Integrity.StatusOn("SQL01", states)); // a real gap stays a gap
Assert.Equal(ControlStatus.Met, Integrity.StatusOn("SQL02", states));
Assert.Equal(ControlStatus.NotMet, Integrity.StatusOn("SQL03", states)); // a finding always counts
}
}

View file

@ -1,133 +0,0 @@
using SQLInsight.Core.Data;
using SQLInsight.Reports;
using SQLInsight.Repository;
namespace SQLInsight.Agent.Tests;
public class TimeWindowTests
{
private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
private static readonly DateTimeOffset Now = new(2026, 10, 1, 10, 0, 0, TimeSpan.Zero);
[Fact]
public void Local_input_is_converted_to_utc_in_the_display_time_zone()
{
// 20 September 2026 is summer time in Copenhagen (UTC+2): 03:00–04:00 local is 01:00–02:00 UTC.
Assert.True(TimeWindow.TryParse("2026-09-20T03:00", "2026-09-20T04:00", Copenhagen, out var from, out var to, out var error));
Assert.Null(error);
Assert.Equal(new DateTimeOffset(2026, 9, 20, 1, 0, 0, TimeSpan.Zero), from);
Assert.Equal(new DateTimeOffset(2026, 9, 20, 2, 0, 0, TimeSpan.Zero), to);
Assert.Equal("2026-09-20T03:00", TimeWindow.ToInput(from, Copenhagen));
}
[Fact]
public void Winter_time_and_the_skipped_summer_time_hour_are_handled()
{
Assert.True(TimeWindow.TryParse("2026-12-01T03:00", "2026-12-01T04:00", Copenhagen, out var from, out _, out _));
Assert.Equal(new DateTimeOffset(2026, 12, 1, 2, 0, 0, TimeSpan.Zero), from);
// 29 March 2026 02:30 does not exist in Copenhagen: the hour after is used rather than failing.
Assert.True(TimeWindow.TryParse("2026-03-29T02:30", "2026-03-29T05:00", Copenhagen, out var skipped, out _, out _));
Assert.Equal(new DateTimeOffset(2026, 3, 29, 1, 30, 0, TimeSpan.Zero), skipped);
}
[Theory]
[InlineData("2026-09-20T04:00", "2026-09-20T03:00", "Til skal ligge efter fra.")]
[InlineData("2026-08-01T00:00", "2026-09-20T03:00", "Højst 31 dage ad gangen.")]
[InlineData("i går", "2026-09-20T03:00", "Angiv fra og til som dato og klokkeslæt.")]
public void Invalid_windows_are_refused_with_a_reason(string from, string to, string expected)
{
Assert.False(TimeWindow.TryParse(from, to, Copenhagen, out _, out _, out var error));
Assert.Equal(expected, error);
}
[Fact]
public void Raw_samples_are_used_inside_their_retention_and_hourly_history_before()
{
Assert.Equal(WindowResolution.Raw, TimeWindow.ChooseResolution(Now.AddDays(-13), Now, rawDays: 14));
Assert.Equal(WindowResolution.Hourly, TimeWindow.ChooseResolution(Now.AddDays(-15), Now, rawDays: 14));
}
[Fact]
public void Presets_follow_the_local_calendar()
{
// 10:00 UTC = 12:00 in Copenhagen.
var (nightFrom, nightTo) = TimeWindow.Preset("i-nat", Now, Copenhagen)!.Value;
Assert.Equal(new DateTimeOffset(2026, 9, 30, 22, 0, 0, TimeSpan.Zero), nightFrom);
Assert.Equal(new DateTimeOffset(2026, 10, 1, 4, 0, 0, TimeSpan.Zero), nightTo);
var (yesterdayFrom, yesterdayTo) = TimeWindow.Preset("i-gaar", Now, Copenhagen)!.Value;
Assert.Equal(new DateTimeOffset(2026, 9, 29, 22, 0, 0, TimeSpan.Zero), yesterdayFrom);
Assert.Equal(new DateTimeOffset(2026, 9, 30, 22, 0, 0, TimeSpan.Zero), yesterdayTo);
// Before 06 local, "i nat" ends now.
var early = new DateTimeOffset(2026, 10, 1, 2, 0, 0, TimeSpan.Zero);
Assert.Equal(early, TimeWindow.Preset("i-nat", early, Copenhagen)!.Value.To);
Assert.Null(TimeWindow.Preset("ukendt", Now, Copenhagen));
}
[Fact]
public void Waits_are_compared_per_minute_with_the_weeks_that_have_history()
{
var window = new List<WaitTotal>
{
new("LCK_M_X", 50, 600_000, 0), // 10 min in a 60-minute window = 10 000 ms/min
new("PAGEIOLATCH_SH", 1_000, 60_000, 0), // 1 min = 1 000 ms/min
};
IReadOnlyList<IReadOnlyList<HourlyWaitTotal>> weeks =
[
[new("LCK_M_X", 60_000, 10, 1), new("PAGEIOLATCH_SH", 60_000, 900, 1)], // 1 000 ms/min each
[new("LCK_M_X", 180_000, 10, 1), new("PAGEIOLATCH_SH", 60_000, 900, 1)], // 3 000 and 1 000
[], // no data that week: ignored
];
var result = WindowBaseline.CompareWaits(window, 60, weeks);
var locks = result[0];
Assert.Equal("LCK_M_X", locks.WaitType);
Assert.Equal(10_000, locks.PerMinute);
Assert.Equal(2_000, locks.NormalPerMinute);
Assert.Equal(5, locks.Ratio);
Assert.True(locks.IsUnusual);
var io = result[1];
Assert.Equal(1_000, io.NormalPerMinute);
Assert.False(io.IsUnusual);
}
[Fact]
public void Without_history_the_normal_is_unknown_and_nothing_is_flagged_by_ratio()
{
var result = WindowBaseline.CompareWaits([new WaitTotal("CXPACKET", 1, 1_000, 0)], 60, [[], []]);
Assert.Null(result[0].NormalPerMinute);
Assert.Null(result[0].Ratio);
Assert.False(result[0].IsUnusual);
}
[Fact]
public void Metric_is_unusual_when_clearly_worse_than_normal()
{
var at = Now.UtcDateTime;
var cpu = new MetricComparison("cpu", "CPU", " %", 0, LowerIsWorse: false, 100, [new MetricPoint(at, 80), new MetricPoint(at.AddMinutes(5), 90)],
WindowBaseline.NormalAverage([[new MetricPoint(at.AddDays(-7), 30)], [new MetricPoint(at.AddDays(-14), 50)]]));
Assert.Equal(40, cpu.NormalAverage);
Assert.True(cpu.IsUnusual);
var ple = new MetricComparison("ple", "PLE", "", 0, LowerIsWorse: true, null, [new MetricPoint(at, 200)], 1_000);
Assert.True(ple.IsUnusual);
var normal = new MetricComparison("cpu", "CPU", " %", 0, false, 100, [new MetricPoint(at, 42)], 40);
Assert.False(normal.IsUnusual);
Assert.Null(WindowBaseline.NormalAverage([[], []]));
}
[Theory]
[InlineData("varighed", TopQueryOrder.Duration)]
[InlineData("laesninger", TopQueryOrder.Reads)]
[InlineData("koersler", TopQueryOrder.Executions)]
[InlineData(null, TopQueryOrder.Cpu)]
[InlineData("noget-andet", TopQueryOrder.Cpu)]
public void Order_comes_from_the_url(string? value, TopQueryOrder expected) => Assert.Equal(expected, AnalysisOrder.Parse(value));
}

View file

@ -1,135 +0,0 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Tests;
/// <summary>
/// SQL Server 2005 cannot run in CI (no container image), and its T-SQL lacks much of 2008's. Every collector that can
/// run on 2005 (all its required capabilities exist from 9.0) is checked for 2008+ syntax in its SQL, and so is every
/// community rule that does not ask for 10.0 or later. A collector with version branches must keep 2008+ syntax in the
/// branch for newer versions; those few are listed with what they gate.
/// </summary>
public sealed partial class Sql2005CompatibilityTests
{
/// <summary>2008+ constructs that make a whole batch fail on 2005, whatever branch it was meant for.</summary>
private static readonly (string Name, Regex Pattern)[] Sql2008Only =
[
("DECLARE med startværdi", new Regex(@"DECLARE\s+@\w+\s+\w+(\s*\([\d\s,]+\))?\s*=", RegexOptions.IgnoreCase)),
("+= / -= på variabel", new Regex(@"@\w+\s*[+\-*/]=", RegexOptions.IgnoreCase)),
("MERGE", new Regex(@"\bMERGE\s", RegexOptions.IgnoreCase)),
("CONCAT/IIF/CHOOSE/FORMAT/TRY_*", new Regex(@"\b(CONCAT|IIF|CHOOSE|FORMAT|TRY_CONVERT|TRY_CAST|TRY_PARSE|EOMONTH|DATEFROMPARTS|STRING_AGG|STRING_SPLIT)\s*\(", RegexOptions.IgnoreCase)),
("OFFSET … FETCH", new Regex(@"\bOFFSET\s+\S+\s+ROWS\b", RegexOptions.IgnoreCase)),
("date/time/datetime2/datetimeoffset", new Regex(@"\bAS\s+(date|time|datetime2|datetimeoffset)\b|\b(datetime2|datetimeoffset)\s*\(", RegexOptions.IgnoreCase)),
("VALUES med flere rækker", new Regex(@"\bVALUES\s*\([^)]*\)\s*,\s*\(", RegexOptions.IgnoreCase)),
];
/// <summary>
/// Collectors that run on 2005 but deliberately contain 2008+ syntax in a branch that 2005 never takes.
/// Only patterns from <see cref="Sql2008Only"/> listed here are tolerated, and only in that file.
/// </summary>
private static readonly Dictionary<string, string[]> VersionBranched = new(StringComparer.Ordinal)
{
// Query Store queries only run when Capability.QueryStore is present (2016+).
["QueryStoreCollector"] = ["DECLARE med startværdi", "date/time/datetime2/datetimeoffset"],
};
[Fact]
public void Collectors_that_run_on_sql_server_2005_use_only_2005_syntax()
{
var problems = new List<string>();
foreach (var collector in BuiltInCollectors.Create())
{
var runsOn2005 = collector.RequiredCapabilities.All(c => InstanceCapabilities.MinimumVersionFor(c) is not { Major: > 9 });
if (!runsOn2005)
{
continue;
}
var name = collector.GetType().Name;
var source = SourceOf(name);
VersionBranched.TryGetValue(name, out var tolerated);
foreach (Match literal in RawString().Matches(source))
{
foreach (var (pattern, regex) in Sql2008Only)
{
if (regex.IsMatch(literal.Value) && tolerated?.Contains(pattern) != true)
{
problems.Add($"{name}: {pattern}");
}
}
}
}
Assert.True(problems.Count == 0, "SQL Server 2008+-syntaks i SQL, der også køres på 2005: " + string.Join("; ", problems.Distinct()));
}
[Fact]
public void Community_rules_for_sql_server_2005_use_only_2005_syntax()
{
var problems = new List<string>();
foreach (var file in Directory.EnumerateFiles(Path.Combine(RepositoryRoot(), "rules", "community"), "*.json"))
{
using var document = JsonDocument.Parse(File.ReadAllText(file), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
var rules = document.RootElement.ValueKind == JsonValueKind.Array ? document.RootElement.EnumerateArray().ToList() : [document.RootElement];
foreach (var rule in rules)
{
var minimum = rule.TryGetProperty("minimumVersion", out var v) ? v.GetString() : "9.0";
if (SqlServerVersion.Parse(minimum + ".0.0").Major > 9)
{
continue;
}
var query = rule.GetProperty("query").GetString()!;
problems.AddRange(Sql2008Only.Where(p => p.Pattern.IsMatch(query)).Select(p => $"{rule.GetProperty("id").GetString()}: {p.Name}"));
}
}
Assert.True(problems.Count == 0, "SQL Server 2008+-syntaks i regler uden minimumVersion 10.0: " + string.Join("; ", problems));
}
[Fact]
public void Plan_analysis_and_top_sql_identify_statements_without_query_hash_on_2005()
{
var sql2005 = new[]
{
Performance.PlanAnalysisCollector.BuildSql(memoryColumns: false, queryHash: false),
Performance.QueryStatsCollector.SqlFor(SqlServerVersion.Parse("9.0.5057.0"), new DateTime(2026, 10, 1)),
};
Assert.All(sql2005, sql => Assert.DoesNotContain("query_hash", sql, StringComparison.OrdinalIgnoreCase));
Assert.All(sql2005, sql => Assert.Contains("sys.fn_varbintohexstr(", sql, StringComparison.Ordinal));
Assert.Contains("query_hash", Performance.PlanAnalysisCollector.BuildSql(memoryColumns: true), StringComparison.Ordinal);
}
[Theory]
[InlineData("DECLARE @since int = CONVERT(int, 1);", true)]
[InlineData("DECLARE @x nvarchar(60) = N'a';", true)]
[InlineData("DECLARE @since int;\nSET @since = 1;", false)]
[InlineData("SET @n += 1", true)]
[InlineData("SELECT CONVERT(datetime, '2026-10-01', 126)", false)]
public void The_check_itself_recognises_2008_syntax(string sql, bool expected) =>
Assert.Equal(expected, Sql2008Only.Any(p => p.Pattern.IsMatch(sql)));
private static string SourceOf(string typeName)
{
var folder = Path.Combine(RepositoryRoot(), "src", "SQLInsight.Collectors");
var file = Directory.EnumerateFiles(folder, typeName + ".cs", SearchOption.AllDirectories).SingleOrDefault();
return file is null ? throw new InvalidOperationException($"{typeName}.cs ikke fundet") : File.ReadAllText(file);
}
private static string RepositoryRoot()
{
var root = AppContext.BaseDirectory;
while (!File.Exists(Path.Combine(root, "SQLInsight.slnx")))
{
root = Path.GetDirectoryName(root) ?? throw new InvalidOperationException("SQLInsight.slnx ikke fundet.");
}
return root;
}
// C# raw string literals ("""…""" and $"""…"""): where the collectors keep their SQL.
[GeneratedRegex("\"\"\"(.*?)\"\"\"", RegexOptions.Singleline)]
private static partial Regex RawString();
}

View file

@ -1,57 +0,0 @@
using System.Text.RegularExpressions;
namespace SQLInsight.Community.Tests;
/// <summary>
/// A table cell must stay a table cell. A class that sets display: flex or grid turns a &lt;td&gt;/&lt;th&gt; into a flex box,
/// so it gets its own height and border and the row's divider jumps (the severity badges on the overviews did that).
/// Such classes belong on an element inside the cell. Checked for every site's stylesheet against its own pages.
/// </summary>
public sealed partial class TableMarkupTests
{
[Theory]
[InlineData("SQLInsight.Web")]
[InlineData("SQLInsight.Hub")]
[InlineData("SQLInsight.Community")]
public void No_table_cell_has_a_flex_or_grid_class(string project)
{
var folder = Path.Combine(RepositoryRoot(), "src", project);
var layoutClasses = Directory.EnumerateFiles(Path.Combine(folder, "wwwroot"), "*.css", SearchOption.AllDirectories)
.SelectMany(css => FlexOrGridRule().Matches(File.ReadAllText(css)))
.SelectMany(rule => ClassName().Matches(rule.Groups["selectors"].Value).Select(c => c.Groups[1].Value))
.ToHashSet(StringComparer.Ordinal);
var offenders = Directory.EnumerateFiles(folder, "*.razor", SearchOption.AllDirectories)
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
&& !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.SelectMany(file => CellClass().Matches(File.ReadAllText(file))
.SelectMany(cell => cell.Groups["classes"].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Where(layoutClasses.Contains)
.Select(c => $"{Path.GetFileName(file)}: <td/th class=\"{c}\">"))
.ToList();
Assert.True(offenders.Count == 0, "Flyt klassen ind på et element i cellen: " + string.Join(", ", offenders));
}
private static string RepositoryRoot()
{
var root = AppContext.BaseDirectory;
while (!File.Exists(Path.Combine(root, "SQLInsight.slnx")))
{
root = Path.GetDirectoryName(root) ?? throw new InvalidOperationException("SQLInsight.slnx ikke fundet.");
}
return root;
}
// A rule whose declarations set display to (inline-)flex or (inline-)grid; media queries are fine, their inner rules match.
[GeneratedRegex(@"(?<selectors>[^{}]+)\{[^{}]*\bdisplay\s*:\s*(inline-)?(flex|grid)\b")]
private static partial Regex FlexOrGridRule();
[GeneratedRegex(@"\.([A-Za-z][\w-]*)")]
private static partial Regex ClassName();
// Literal class names only; Razor expressions (@…) in the attribute are skipped by splitting on spaces.
[GeneratedRegex(@"<t[dh]\b[^>]*\bclass=""(?<classes>[^""]*)""")]
private static partial Regex CellClass();
}

View file

@ -1,134 +0,0 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Core.Tests;
public class QueryStatsDeltaTests
{
private static readonly DateTime Start = new(2026, 9, 20, 0, 0, 0);
private static readonly DateTimeOffset T0 = new(2026, 9, 20, 3, 0, 0, TimeSpan.Zero);
private static QueryStat Stat(string key, long executions, long cpu, DateTime created, DateTime lastRun, long reads = 0) => new()
{
Key = key,
Text = "SELECT " + key,
Executions = executions,
WorkerTimeUs = cpu,
ElapsedTimeUs = cpu * 2,
LogicalReads = reads,
PhysicalReads = 0,
LogicalWrites = 0,
CreatedAt = created,
LastExecutionAt = lastRun,
};
private static QueryStatsSnapshot Snapshot(DateTimeOffset at, DateTime serverTime, params QueryStat[] queries) => new()
{
CapturedAt = at,
ServerTime = serverTime,
SqlServerStartTime = Start,
Queries = queries,
};
[Fact]
public void Statement_seen_before_with_the_same_plan_gets_the_difference()
{
var created = new DateTime(2026, 9, 20, 1, 0, 0);
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xA", 100, 5_000, created, created.AddHours(1), reads: 1_000));
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0), Stat("0xA", 130, 8_000, created, new DateTime(2026, 9, 20, 3, 4, 0), reads: 1_600));
var delta = QueryStatsDelta.Compute(before, now)!;
var q = Assert.Single(delta.Queries);
Assert.Equal(30, q.Executions);
Assert.Equal(3_000, q.WorkerTimeUs);
Assert.Equal(6_000, q.ElapsedTimeUs);
Assert.Equal(600, q.LogicalReads);
Assert.Equal("SELECT 0xA", q.Text);
}
[Fact]
public void Plan_compiled_after_the_previous_collection_counts_in_full()
{
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0));
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
Stat("0xB", 4, 900, new DateTime(2026, 9, 20, 3, 2, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
var q = Assert.Single(QueryStatsDelta.Compute(before, now)!.Queries);
Assert.Equal(4, q.Executions);
Assert.Equal(900, q.WorkerTimeUs);
}
[Fact]
public void Old_statement_seen_for_the_first_time_has_no_known_starting_point()
{
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0));
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
Stat("0xC", 1_000_000, 9_000_000, new DateTime(2026, 9, 19, 8, 0, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
Assert.Empty(QueryStatsDelta.Compute(before, now)!.Queries);
}
[Fact]
public void Recompiled_plan_starts_over_without_negative_numbers()
{
var oldPlan = new DateTime(2026, 9, 20, 1, 0, 0);
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xD", 500, 50_000, oldPlan, oldPlan.AddHours(1)));
// Evicted and compiled again at 03:03: smaller totals, newer creation time. Everything since 03:03 is new.
var recompiled = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
Stat("0xD", 7, 700, new DateTime(2026, 9, 20, 3, 3, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
var q = Assert.Single(QueryStatsDelta.Compute(before, recompiled)!.Queries);
Assert.Equal(7, q.Executions);
Assert.Equal(700, q.WorkerTimeUs);
// Counters went backwards but the oldest plan is from before: one of several plans was evicted. Skip, never negative.
var partlyEvicted = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
Stat("0xD", 300, 30_000, oldPlan, new DateTime(2026, 9, 20, 3, 4, 0)));
Assert.Empty(QueryStatsDelta.Compute(before, partlyEvicted)!.Queries);
}
[Fact]
public void Restart_gives_no_delta_and_forgets_remembered_statements()
{
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xE", 1, 1, Start, Start));
var after = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0)) with { SqlServerStartTime = Start.AddHours(3) };
Assert.Null(QueryStatsDelta.Compute(before, after));
Assert.Empty(QueryStatsDelta.Remember(before, [], after.SqlServerStartTime));
}
[Fact]
public void Statements_not_run_now_are_remembered_for_later_up_to_a_limit()
{
var created = new DateTime(2026, 9, 20, 1, 0, 0);
var previous = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0),
[.. Enumerable.Range(0, QueryStatsSnapshot.MaxRemembered + 10).Select(i => Stat("0x" + i, 1, 1, created, created.AddSeconds(i)))]);
var current = new[] { Stat("0xNEW", 1, 1, created, created.AddHours(2)) };
var kept = QueryStatsDelta.Remember(previous, current, Start);
Assert.Equal(QueryStatsSnapshot.MaxRemembered, kept.Count);
Assert.Equal("0xNEW", kept[0].Key);
Assert.DoesNotContain(kept, q => q.Key == "0x0"); // least recently run is forgotten first
}
[Fact]
public void Sql_server_2005_key_is_handle_and_offsets()
{
// The key is whatever the collector builds; on 2005 sql_handle:start:end, so the same batch's statements stay apart.
var created = new DateTime(2026, 9, 20, 1, 0, 0);
var first = "0x0200000012ab:0:120";
var second = "0x0200000012ab:122:-1";
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat(first, 10, 100, created, created), Stat(second, 10, 100, created, created));
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
Stat(first, 12, 160, created, new DateTime(2026, 9, 20, 3, 1, 0)), Stat(second, 15, 400, created, new DateTime(2026, 9, 20, 3, 1, 0)));
var delta = QueryStatsDelta.Compute(before, now)!;
Assert.Equal(2, delta.Queries.Count);
Assert.Equal(second, delta.Queries[0].Key); // most CPU first
Assert.Equal(300, delta.Queries[0].WorkerTimeUs);
Assert.Equal(60, delta.Queries[1].WorkerTimeUs);
}
}

View file

@ -1,218 +0,0 @@
using Dapper;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using SQLInsight.Collectors.Performance;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
using SQLInsight.Reports;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// Top SQL against real SQL Server (as the least-privilege agent), and the time window analysis on stored data:
/// repository queries, the web page and the PDF.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime
{
private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
private readonly string _database = "SQLInsightWindow_" + Guid.NewGuid().ToString("N")[..8];
private InsightRepository _repository = null!;
private int _instanceId;
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
var info = new InstanceInfo
{
ServerName = "AX01",
Version = SqlServerVersion.Parse("16.0.4135.4"),
ProductLevel = "RTM",
Edition = "Standard Edition",
EngineEdition = EngineEdition.Standard,
};
_instanceId = await _repository.UpsertInstanceAsync(@"AX01\AX30", info, "SqlClient", false, DateTimeOffset.UtcNow);
}
public async Task DisposeAsync() =>
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
[Fact]
public async Task Collector_turns_query_stats_into_deltas_with_text()
{
var table = "QsProbe_" + Guid.NewGuid().ToString("N")[..8];
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; CREATE TABLE dbo.{table} (Id int NOT NULL PRIMARY KEY, Amount money NOT NULL); INSERT dbo.{table} SELECT TOP (2000) ROW_NUMBER() OVER (ORDER BY (SELECT 1)), 1 FROM sys.all_columns a CROSS JOIN sys.all_columns b;");
var probe = $"SELECT SUM(p.Amount) FROM dbo.{table} p JOIN dbo.{table} q ON q.Id = p.Id WHERE p.Id > 0";
await using var session = await AgentSession.OpenAsync(sql);
var collector = new QueryStatsCollector();
var first = await session.CollectAsync<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>(collector);
await Task.Delay(1100);
for (var i = 0; i < 5; i++)
{
await RunInTestDatabaseAsync(probe);
}
var second = await session.CollectAsync(collector, first);
await Task.Delay(1100);
for (var i = 0; i < 3; i++)
{
await RunInTestDatabaseAsync(probe);
}
var third = await session.CollectAsync(collector, second);
Assert.Null(first.Delta);
// Compiled after the first collection: all five executions are new.
var compiled = Assert.Single(second.Delta!.Queries, q => q.Text?.Contains(table, StringComparison.Ordinal) == true);
Assert.Equal(5, compiled.Executions);
Assert.True(compiled.WorkerTimeUs > 0);
Assert.True(compiled.LogicalReads > 0);
Assert.Equal(SqlServerFixture.TestDatabase, compiled.DatabaseName);
Assert.StartsWith("0x", compiled.Key, StringComparison.Ordinal);
// Same plan: only the difference.
Assert.Equal(3, Assert.Single(third.Delta!.Queries, q => q.Key == compiled.Key).Executions);
Assert.Contains(third.Snapshot.Queries, q => q.Key == compiled.Key);
}
[Fact]
public async Task Repository_sums_top_sql_per_window_and_never_keeps_it_as_a_snapshot()
{
var now = DateTimeOffset.UtcNow;
await SaveQueryDeltaAsync(now.AddMinutes(-20), ("0xAAA", "SELECT * FROM CUSTTABLE", 10, 4_000_000), ("0xBBB", "SELECT * FROM INVENTTRANS", 1, 9_000_000));
await SaveQueryDeltaAsync(now.AddMinutes(-10), ("0xAAA", "SELECT * FROM CUSTTABLE", 5, 2_000_000));
await SaveQueryDeltaAsync(now.AddHours(-5), ("0xAAA", "SELECT * FROM CUSTTABLE", 100, 100_000_000)); // outside the window
var byCpu = await _repository.GetTopQueriesAsync(_instanceId, now.AddHours(-1), now);
var byExecutions = await _repository.GetTopQueriesAsync(_instanceId, now.AddHours(-1), now, TopQueryOrder.Executions);
Assert.Equal(["0xBBB", "0xAAA"], byCpu.Select(q => q.QueryKey));
var custtable = byCpu.Single(q => q.QueryKey == "0xAAA");
Assert.Equal(15, custtable.Executions);
Assert.Equal(6_000_000, custtable.WorkerTimeUs);
Assert.Equal("SELECT * FROM CUSTTABLE", custtable.Text);
Assert.Equal("0xAAA", byExecutions[0].QueryKey);
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
Assert.Equal(0, await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM insight.Snapshots WHERE Kind = 'query-stats' OR Json LIKE '%CUSTTABLE%'"));
}
[Fact]
public async Task Analysis_compares_the_window_with_the_same_hours_in_earlier_weeks()
{
var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1);
var from = to.AddHours(-1);
await SeedWindowAsync(from, to);
var analysis = await TimeWindowAnalysis.LoadAsync(_repository, (await _repository.GetInstanceAsync(@"AX01\AX30"))!, from, to, DateTimeOffset.UtcNow);
Assert.Equal(WindowResolution.Raw, analysis.Resolution);
Assert.True(analysis.HasData);
var locks = analysis.Waits.Single(w => w.WaitType == "LCK_M_X");
Assert.Equal(1_000, locks.NormalPerMinute); // 60 s per hour in each earlier week
Assert.True(locks.IsUnusual);
var cpu = analysis.Metrics.Single(m => m.Metric == Metrics.CpuSqlServer);
Assert.Equal(90, cpu.Average);
Assert.Equal(30, cpu.NormalAverage);
Assert.True(cpu.IsUnusual);
Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text);
Assert.Single(analysis.Blocking);
}
[Fact]
public async Task Page_and_pdf_show_the_window_and_explain_an_empty_one()
{
var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1);
var from = to.AddHours(-1);
await SeedWindowAsync(from, to);
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
b.UseSetting("SQLInsight:Web:TimeZone", "Europe/Copenhagen");
});
var client = factory.CreateClient();
var name = Uri.EscapeDataString(@"AX01\AX30");
static string Window(DateTimeOffset f, DateTimeOffset t) =>
$"fra={Uri.EscapeDataString(TimeWindow.ToInput(f, Copenhagen))}&til={Uri.EscapeDataString(TimeWindow.ToInput(t, Copenhagen))}";
var html = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from, to)}");
Assert.Contains("SELECT * FROM SALESLINE", html, StringComparison.Ordinal);
Assert.Contains("LCK_M_X", html, StringComparison.Ordinal);
Assert.Contains("class=\"unusual\"", html, StringComparison.Ordinal);
Assert.Contains("value=\"" + TimeWindow.ToInput(from, Copenhagen) + "\"", html, StringComparison.Ordinal);
var empty = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from.AddDays(-200), to.AddDays(-200))}");
Assert.Contains("Der er ingen målinger i dette tidsrum", empty, StringComparison.Ordinal);
var invalid = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(to, from)}");
Assert.Contains("Til skal ligge efter fra.", invalid, StringComparison.Ordinal);
var instancePage = await client.GetStringAsync($"/instanser/{name}");
Assert.Contains($"/instanser/{name}/analyse", instancePage, StringComparison.Ordinal);
using var pdf = await client.GetAsync($"/analyse.pdf?instans={name}&{Window(from, to)}");
Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
Assert.StartsWith("%PDF", System.Text.Encoding.ASCII.GetString(await pdf.Content.ReadAsByteArrayAsync())[..4], StringComparison.Ordinal);
}
/// <summary>A busy hour: high CPU, lock waits and one heavy statement, against quiet same hours four weeks back.</summary>
private async Task SeedWindowAsync(DateTimeOffset from, DateTimeOffset to)
{
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
for (var at = from.AddMinutes(5); at <= to; at = at.AddMinutes(5))
{
await connection.ExecuteAsync("""
INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES (@id, @cpu, @at, 90), (@id, @batches, @at, 450);
INSERT insight.WaitSamples (InstanceId, SampledAt, WaitType, IntervalSeconds, WaitingTasks, WaitTimeMs, SignalWaitTimeMs)
VALUES (@id, @at, 'LCK_M_X', 300, 40, 600000, 0), (@id, @at, 'PAGEIOLATCH_SH', 300, 900, 30000, 100);
""", new { id = _instanceId, cpu = Metrics.CpuSqlServer, batches = Metrics.BatchRequestsPerSecond, at = at.UtcDateTime });
}
for (var week = 1; week <= WindowBaseline.Weeks; week++)
{
var hour = from.AddDays(-7 * week).UtcDateTime;
await connection.ExecuteAsync("""
INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES (@id, @cpu, @hour, 30, 40, 12);
INSERT insight.WaitHourly (InstanceId, [Hour], WaitType, WaitingTasks, WaitTimeMs, SignalWaitTimeMs)
VALUES (@id, @hour, 'LCK_M_X', 10, 60000, 0), (@id, @hour, 'PAGEIOLATCH_SH', 10000, 360000, 1000);
""", new { id = _instanceId, cpu = Metrics.CpuSqlServer, hour });
}
await connection.ExecuteAsync("""
INSERT insight.BlockingEvents (InstanceId, SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson)
VALUES (@id, @at, 55, 7, 42000, '[{"SessionId":55,"Status":"sleeping","ProgramName":"Microsoft Dynamics AX","LoginName":"AXSERVICE"}]');
""", new { id = _instanceId, at = from.AddMinutes(20).UtcDateTime });
await SaveQueryDeltaAsync(from.AddMinutes(30), ("0xCCC", "SELECT * FROM SALESLINE", 4000, 120_000_000));
}
private Task SaveQueryDeltaAsync(DateTimeOffset at, params (string Key, string Text, long Executions, long Cpu)[] queries) =>
_repository.SaveCollectedAsync(_instanceId, "query-stats", new CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>(
new QueryStatsSnapshot { CapturedAt = at, ServerTime = at.UtcDateTime, SqlServerStartTime = DateTime.UtcNow.AddDays(-30), Queries = [] },
new QueryStatsDelta
{
From = at.AddMinutes(-5),
To = at,
Queries = [.. queries.Select(q => new QueryStatDelta
{
Key = q.Key, Text = q.Text, DatabaseName = "AX30", Executions = q.Executions, WorkerTimeUs = q.Cpu, ElapsedTimeUs = q.Cpu * 2,
LogicalReads = q.Executions * 100, PhysicalReads = 0, LogicalWrites = 0,
})],
}));
/// <summary>Like an application: connected to its own database, so the plan belongs to it.</summary>
private async Task RunInTestDatabaseAsync(string statement)
{
await using var connection = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
await connection.ExecuteScalarAsync(statement);
}
private static DateTimeOffset FloorHour(DateTimeOffset value) => new(value.Year, value.Month, value.Day, value.Hour, 0, 0, value.Offset);
}

View file

@ -104,29 +104,6 @@ public class WebTests(SqlServerFixture sql) : IAsyncLifetime
Assert.Contains($"/deadlocks/{_deadlockId}.xdl", html, StringComparison.Ordinal); Assert.Contains($"/deadlocks/{_deadlockId}.xdl", html, StringComparison.Ordinal);
} }
[Fact]
public async Task Rules_not_applicable_to_the_version_are_collapsed_and_not_counted_as_not_measurable()
{
var id = (await _repository.GetInstanceAsync("prod-sql01"))!.InstanceId;
await _repository.SaveRuleResultsAsync(id,
[
new RuleResult { RuleId = "SQLI-QS-001", RuleTitle = "Query Store er slået fra", Outcome = RuleOutcome.NotApplicable, Reason = "Ikke relevant for SQL Server 2019: kræver noget nyere." },
new RuleResult { RuleId = "SQLI-QS-002", RuleTitle = "Query Store er skrivebeskyttet", Outcome = RuleOutcome.NotApplicable, Reason = "Ikke relevant for SQL Server 2019: kræver noget nyere." },
], DateTimeOffset.UtcNow);
var overview = (await _repository.GetInstancesAsync()).Single(i => i.Name == "prod-sql01");
Assert.Equal(1, overview.NotMeasurable); // only SQLI-DB-011, a real gap
var html = await _factory.CreateClient().GetStringAsync("/instanser/prod-sql01");
var notMeasurable = html[html.IndexOf("Ikke målbart på denne instans", StringComparison.Ordinal)..];
var collapsed = notMeasurable[notMeasurable.IndexOf("<details class=\"not-applicable\">", StringComparison.Ordinal)..];
Assert.Contains("Ikke relevant for SQL Server 2019 (2)", collapsed, StringComparison.Ordinal);
Assert.Contains("SQLI-QS-001", collapsed, StringComparison.Ordinal);
// The real gap stays visible, before and outside the collapsed list.
Assert.DoesNotContain("SQLI-DB-011", collapsed, StringComparison.Ordinal);
Assert.Contains("SQLI-DB-011", notMeasurable, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded() public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded()
{ {

View file

@ -115,13 +115,13 @@ public class CustomRuleTests
} }
[Fact] [Fact]
public void Rules_needing_a_newer_version_are_not_applicable() public void Rules_needing_a_newer_version_are_not_measurable()
{ {
var rule = Rule(Valid.Replace("\"severity\"", "\"minimumVersion\": \"13.0\", \"severity\"", StringComparison.Ordinal)); var rule = Rule(Valid.Replace("\"severity\"", "\"minimumVersion\": \"13.0\", \"severity\"", StringComparison.Ordinal));
var result = Assert.Single(new RuleEngine([], Catalog(rule)).Evaluate(TestInstances.Context("11.0.7001.0"))); var result = Assert.Single(new RuleEngine([], Catalog(rule)).Evaluate(TestInstances.Context("11.0.7001.0")));
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("13.0", result.Reason, StringComparison.Ordinal); Assert.Contains("13.0", result.Reason, StringComparison.Ordinal);
} }

View file

@ -147,11 +147,11 @@ public class DatabaseRuleTests
} }
[Fact] [Fact]
public void CheckDb_is_not_applicable_before_2016_sp2() public void CheckDb_is_not_measurable_before_2016_sp2()
{ {
var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database()))); var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database())));
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
} }
[Fact] [Fact]

View file

@ -30,7 +30,7 @@ public class ManagedInstanceTests
foreach (var id in new[] { "SQLI-VER-001", "SQLI-VER-002", "SQLI-CFG-001", "SQLI-DB-007", "SQLI-DB-009", "SQLI-DB-015" }) foreach (var id in new[] { "SQLI-VER-001", "SQLI-VER-002", "SQLI-CFG-001", "SQLI-DB-007", "SQLI-DB-009", "SQLI-DB-015" })
{ {
var result = results.Single(r => r.RuleId == id); var result = results.Single(r => r.RuleId == id);
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Equal("Håndteres af Azure på Azure SQL Managed Instance.", result.Reason); Assert.Equal("Håndteres af Azure på Azure SQL Managed Instance.", result.Reason);
} }
} }

View file

@ -86,9 +86,9 @@ public class PlanRuleTests
} }
[Fact] [Fact]
public void Memory_rules_are_not_applicable_without_the_columns() public void Memory_rules_are_not_measurable_without_the_columns()
{ {
Assert.Equal(RuleOutcome.NotApplicable, Run(new SpillRule(), WithVersion("14.0.1000.169", new PlanAnalysisSnapshot { CapturedAt = Now, Statements = [] })).Outcome); Assert.Equal(RuleOutcome.NotMeasurable, Run(new SpillRule(), WithVersion("14.0.1000.169", new PlanAnalysisSnapshot { CapturedAt = Now, Statements = [] })).Outcome);
} }
[Fact] [Fact]

View file

@ -121,10 +121,10 @@ public class QueryStoreRuleTests
} }
[Fact] [Fact]
public void Query_store_rules_are_not_applicable_before_2016() public void Query_store_rules_are_not_measurable_before_2016()
{ {
var result = Run(new PlanRegressionRule(), WithVersion("12.0.6024.0")); var result = Run(new PlanRegressionRule(), WithVersion("12.0.6024.0"));
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
} }
} }

View file

@ -21,62 +21,19 @@ public class RuleEngineTests
} }
[Fact] [Fact]
public void Rule_needing_what_the_version_lacks_is_not_applicable_and_never_runs() public void Rule_missing_capabilities_is_not_measurable_and_never_runs()
{ {
var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed()); var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed());
var engine = new RuleEngine([rule]); var engine = new RuleEngine([rule]);
var result = Assert.Single(engine.Evaluate(TestInstances.Context("9.0.5057.0"))); var result = Assert.Single(engine.Evaluate(TestInstances.Context("11.0.7001.0")));
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome);
Assert.Equal([Capability.QueryStore], result.MissingCapabilities);
Assert.Equal("Ikke relevant for SQL Server 2005: kræver QueryStore (fra SQL Server 2016).", result.Reason);
Assert.Equal(0, rule.Calls);
}
[Fact]
public void Rule_needing_a_setting_the_instance_lacks_is_still_not_measurable()
{
// Always On not enabled is a setting, not the version: the rule could run, so it is a gap, not "not applicable".
var rule = new StubRule("AG", [Capability.HadrEnabled], _ => RuleEvaluation.Passed());
var result = Assert.Single(new RuleEngine([rule]).Evaluate(TestInstances.Context("16.0.4135.4")));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("Ikke målbar", result.Reason, StringComparison.Ordinal); Assert.Equal([Capability.QueryStore], result.MissingCapabilities);
Assert.Contains("SQL Server 2012", result.Reason, StringComparison.Ordinal);
Assert.Equal(0, rule.Calls);
} }
[Fact]
public void Rule_needing_data_the_version_does_not_have_is_not_applicable()
{
var rule = new StubRule("D", [], ctx =>
{
ctx.Data.Get<SQLInsight.Core.Data.DeadlockSnapshot>();
return RuleEvaluation.Passed();
});
var context = TestInstances.Context("9.0.5057.0");
context.Data.MarkNotApplicable(typeof(SQLInsight.Core.Data.DeadlockSnapshot), "Ikke relevant for SQL Server 2005: kræver SystemHealthSession (fra SQL Server 2008).");
var result = Assert.Single(new RuleEngine([rule]).Evaluate(context));
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome);
Assert.Contains("SystemHealthSession", result.Reason, StringComparison.Ordinal);
// Collected later (or failing for another reason): the flag does not stick.
context.Data.MarkUnavailable<SQLInsight.Core.Data.DeadlockSnapshot>("Collector 'deadlocks' fejlede: adgang nægtet.");
Assert.Equal(RuleOutcome.NotMeasurable, Assert.Single(new RuleEngine([rule]).Evaluate(context)).Outcome);
}
[Theory]
[InlineData(Capability.QueryStore, true)]
[InlineData(Capability.SystemHealthSession, true)]
[InlineData(Capability.TextQueryPlan, true)]
[InlineData(Capability.QueryStatsMemoryColumns, true)]
[InlineData(Capability.SelfManagedInfrastructure, true)]
[InlineData(Capability.HadrEnabled, false)]
public void Only_what_the_instance_is_makes_a_rule_not_applicable(Capability capability, bool inherent) =>
Assert.Equal(inherent, InstanceCapabilities.IsInherent(capability));
[Fact] [Fact]
public void Rule_with_capabilities_runs() public void Rule_with_capabilities_runs()
{ {