_urgent = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ _instances = await Repository.GetInstancesAsync();
+ _urgent = (await Repository.GetActiveFindingsAsync()).Where(f => f.Severity >= Severity.High).ToList();
+ }
+}
diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor
new file mode 100644
index 0000000..644e84b
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Pages/Instance.razor
@@ -0,0 +1,235 @@
+@page "/instanser/{Name}"
+@inject InsightRepository Repository
+@inject Display Display
+
+@if (_instance is null)
+{
+ Ukendt instans · SQLInsight Pro
+ Ukendt instans
+ Der findes ingen instans med navnet @Name .
+ return;
+}
+
+@_instance.Name · SQLInsight Pro
+
+Instanser / @_instance.Name
+@_instance.Name
+
+ @_instance.ServerName · @Display.Release(_instance.ProductVersion) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition
+ · sidst set @Display.Ago(_instance.LastSeenAt)
+
+
+@if (_instance.IsFailing)
+{
+
+
Seneste forsøg på at nå instansen fejlede (@Display.Time(_instance.LastErrorAt)):
+
@_instance.LastError
+
+}
+@if (_instance.UsedLegacyFallback)
+{
+ Forbindelsen bruger legacy ODBC-laget, fordi instansen er for gammel til SqlClient. Krypteringen kan være svagere.
+}
+
+
+
Aktive fund @_findings.Count fund
+
+
+
+Belastning, seneste 24 timer
+
+
+
+
Største ventetider, seneste 24 timer uden harmløse baggrundsventetider
+@if (_waits.Count == 0)
+{
+ Endnu ikke nok målinger (ventetider kræver to målinger).
+}
+else
+{
+ var total = Math.Max(1, _waits.Sum(w => w.WaitTimeMs));
+
+ Wait type Kategori Ventetid Andel Gns. pr. wait Signal
+
+ @foreach (var w in _waits)
+ {
+
+ @w.WaitType
+ @w.Category
+ @Display.Number(w.WaitTimeMs / 1000.0, 1) s
+ @Display.Number(100.0 * w.WaitTimeMs / total) %
+ @Display.Number(w.WaitingTasks == 0 ? 0 : (double)w.WaitTimeMs / w.WaitingTasks, 1) ms
+ @Display.Number(w.WaitTimeMs == 0 ? 0 : 100.0 * w.SignalWaitTimeMs / w.WaitTimeMs) %
+
+ }
+
+
+}
+
+
+
+
I/O-latens pr. fil, seneste 24 timer
+@if (_files.Count == 0)
+{
+ Endnu ikke nok målinger.
+}
+else
+{
+
+ Database Fil Type Læsninger Gns. læs Skrivninger Gns. skriv
+
+ @foreach (var f in _files.Take(25))
+ {
+
+ @f.DatabaseName
+ @f.FileId
+ @f.FileType
+ @Display.Number(f.Reads)
+ @Ms(f.AverageReadMs)
+ @Display.Number(f.Writes)
+ @Ms(f.AverageWriteMs)
+
+ }
+
+
+}
+
+
+
+
+
Blocking, seneste 24 timer
+ @if (_blocking.Count == 0)
+ {
+ Ingen blocking over 5 sekunder.
+ }
+ else
+ {
+
+ Tidspunkt Head blocker Blokerede Længste
+
+ @foreach (var b in _blocking.Take(20))
+ {
+ var head = b.Sessions.FirstOrDefault();
+
+ @Display.Time(b.SampledAt)
+ @b.HeadSessionId @head?.Status @head?.ProgramName
+ @b.BlockedCount
+ @Display.Number(b.MaxWaitMs / 1000.0) s
+
+ }
+
+
+ }
+
+
+
Deadlocks, seneste 7 dage
+ @if (_deadlocks.Count == 0)
+ {
+ Ingen deadlocks registreret.
+ }
+ else
+ {
+
+ Tidspunkt Offer Graf
+
+ @foreach (var d in _deadlocks.Take(20))
+ {
+ var victim = d.Processes.FirstOrDefault(p => p.IsVictim);
+
+ @Display.Time(d.OccurredAt)
+ session @victim?.SessionId @victim?.ClientApp
+ .xdl
+
+ }
+
+
+ }
+
+
+
+
+
Ikke målbart på denne instans
+@if (_notMeasurable.Count == 0)
+{
+ Alle regler kunne evalueres.
+}
+else
+{
+ Disse regler kunne ikke vurderes. Det er ikke det samme som "OK".
+
+ Regel Årsag
+
+ @foreach (var r in _notMeasurable)
+ {
+ @r.RuleId @r.RuleTitle @r.Reason
+ }
+
+
+}
+
+
+
+
Løst de seneste 30 dage
+@if (_resolved.Count == 0)
+{
+ Ingen.
+}
+else
+{
+
+ Fund Aktivt fra Løst
+
+ @foreach (var f in _resolved)
+ {
+ @f.Title @Display.Time(f.FirstSeenAt) @Display.Time(f.ResolvedAt)
+ }
+
+
+}
+
+
+@code {
+ [Parameter] public string Name { get; set; } = string.Empty;
+
+ private InstanceOverview? _instance;
+ private IReadOnlyList _findings = [];
+ private IReadOnlyList _resolved = [];
+ private IReadOnlyList _notMeasurable = [];
+ private IReadOnlyList _cpuSql = [], _cpuOther = [], _batches = [], _ple = [];
+ private IReadOnlyList _waits = [];
+ private IReadOnlyList _files = [];
+ private IReadOnlyList _blocking = [];
+ private IReadOnlyList _deadlocks = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ _instance = await Repository.GetInstanceAsync(Name);
+ if (_instance is null)
+ {
+ return;
+ }
+
+ var id = _instance.InstanceId;
+ var day = DateTimeOffset.UtcNow.AddDays(-1);
+ _findings = await Repository.GetActiveFindingsAsync(id);
+ _resolved = await Repository.GetRecentlyResolvedAsync(id, 30);
+ _notMeasurable = (await Repository.GetRuleStatesAsync(id)).Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList();
+ _cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day);
+ _cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day);
+ _batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day);
+ _ple = await Repository.GetMetricAsync(id, Metrics.PageLifeExpectancy, day);
+ _waits = await Repository.GetTopWaitsAsync(id, day, 12);
+ _files = await Repository.GetFileLatencyAsync(id, day);
+ _blocking = await Repository.GetBlockingAsync(id, day);
+ _deadlocks = await Repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddDays(-7));
+ }
+
+ private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
+
+ private static string LatencyClass(double? value, double threshold) => value >= threshold ? "bad" : string.Empty;
+}
diff --git a/src/SQLInsight.Web/Components/Pages/NotFound.razor b/src/SQLInsight.Web/Components/Pages/NotFound.razor
new file mode 100644
index 0000000..0e3e4c6
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Pages/NotFound.razor
@@ -0,0 +1,5 @@
+@page "/ikke-fundet"
+
+Ikke fundet · SQLInsight Pro
+Siden findes ikke
+Tilbage til oversigten
diff --git a/src/SQLInsight.Web/Components/Routes.razor b/src/SQLInsight.Web/Components/Routes.razor
new file mode 100644
index 0000000..b06c87a
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Routes.razor
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/SQLInsight.Web/Components/Shared/FindingList.razor b/src/SQLInsight.Web/Components/Shared/FindingList.razor
new file mode 100644
index 0000000..f1d8eee
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/FindingList.razor
@@ -0,0 +1,32 @@
+@inject Display Display
+
+@if (Findings.Count == 0)
+{
+ @Empty
+}
+else
+{
+
+
+ Alvor @if (ShowInstance) { Instans }Fund Regel Set første gang
+
+
+ @foreach (var f in Findings)
+ {
+
+
+ @if (ShowInstance) { @f.InstanceName }
+ @f.Title
+ @f.RuleId
+ @Display.Ago(f.FirstSeenAt)
+
+ }
+
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IReadOnlyList Findings { get; set; } = [];
+ [Parameter] public bool ShowInstance { get; set; }
+ [Parameter] public string Empty { get; set; } = "Ingen fund.";
+}
diff --git a/src/SQLInsight.Web/Components/Shared/Icon.razor b/src/SQLInsight.Web/Components/Shared/Icon.razor
new file mode 100644
index 0000000..e95dede
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/Icon.razor
@@ -0,0 +1,40 @@
+@* Simple stroke icons (24×24, currentColor). *@
+
+ @switch (Name)
+ {
+ case "servers":
+
+ break;
+ case "alert":
+
+ break;
+ case "list":
+
+ break;
+ case "book":
+
+ break;
+ case "users":
+
+ break;
+ case "support":
+
+ break;
+ case "moon":
+
+ break;
+ case "unplug":
+
+ break;
+ case "gauge":
+
+ break;
+ default:
+
+ break;
+ }
+
+
+@code {
+ [Parameter, EditorRequired] public string Name { get; set; } = string.Empty;
+}
diff --git a/src/SQLInsight.Web/Components/Shared/SeverityBadge.razor b/src/SQLInsight.Web/Components/Shared/SeverityBadge.razor
new file mode 100644
index 0000000..79026c4
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/SeverityBadge.razor
@@ -0,0 +1,6 @@
+@(Severity is { } s ? Display.SeverityName(s) : "OK")@(Count is { } c ? $" {c}" : "")
+
+@code {
+ [Parameter] public Severity? Severity { get; set; }
+ [Parameter] public int? Count { get; set; }
+}
diff --git a/src/SQLInsight.Web/Components/Shared/Sparkline.razor b/src/SQLInsight.Web/Components/Shared/Sparkline.razor
new file mode 100644
index 0000000..13d52ed
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/Sparkline.razor
@@ -0,0 +1,50 @@
+@* A small inline SVG line chart; no JavaScript. *@
+@if (Points.Count < 2)
+{
+ @Label: @Empty
+}
+else
+{
+
+
+ @foreach (var line in GridLines)
+ {
+
+ }
+
+
+
+ @Label
+ gns. @Display.Number(Points.Average(p => p.Value), Decimals)@Unit · maks. @Display.Number(Points.Max(p => p.Value), Decimals)@Unit
+
+
+}
+
+@code {
+ private const int Width = 600;
+ private const int Height = 90;
+
+ [Parameter, EditorRequired] public IReadOnlyList Points { get; set; } = [];
+ [Parameter] public string Label { get; set; } = string.Empty;
+ [Parameter] public string Unit { get; set; } = string.Empty;
+ [Parameter] public double? Max { get; set; }
+ [Parameter] public int Decimals { get; set; }
+ [Parameter] public string Empty { get; set; } = "endnu ikke nok målinger.";
+
+ private double Top => Max ?? Math.Max(1, Points.Max(p => p.Value) * 1.1);
+
+ private IEnumerable GridLines => [Top * 0.25, Top * 0.5, Top * 0.75];
+
+ private string Y(double value) => (Height - value / Top * Height).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture);
+
+ private string Path
+ {
+ get
+ {
+ var start = Points[0].At.Ticks;
+ var span = Math.Max(1, Points[^1].At.Ticks - start);
+ return string.Join(' ', Points.Select(p =>
+ ((double)(p.At.Ticks - start) / span * Width).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) + "," + Y(p.Value)));
+ }
+ }
+}
diff --git a/src/SQLInsight.Web/Components/_Imports.razor b/src/SQLInsight.Web/Components/_Imports.razor
new file mode 100644
index 0000000..8f223d9
--- /dev/null
+++ b/src/SQLInsight.Web/Components/_Imports.razor
@@ -0,0 +1,12 @@
+@using System.Net.Http
+@using Microsoft.AspNetCore.Components
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using SQLInsight.Core.Data
+@using SQLInsight.Core.Findings
+@using SQLInsight.Repository
+@using SQLInsight.Rules.Engine
+@using SQLInsight.Web
+@using SQLInsight.Web.Components
+@using SQLInsight.Web.Components.Layout
+@using SQLInsight.Web.Components.Shared
diff --git a/src/SQLInsight.Web/Display.cs b/src/SQLInsight.Web/Display.cs
new file mode 100644
index 0000000..d2395e8
--- /dev/null
+++ b/src/SQLInsight.Web/Display.cs
@@ -0,0 +1,60 @@
+using System.Globalization;
+using Microsoft.Extensions.Options;
+using SQLInsight.Core.Findings;
+using SQLInsight.Core.Instances;
+
+namespace SQLInsight.Web;
+
+///
+/// Formatting for the UI: times in the configured time zone, Danish number format, severity names.
+///
+public sealed class Display(IOptions options)
+{
+ private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
+
+ private readonly TimeZoneInfo _zone = options.Value.TimeZone is { Length: > 0 } id
+ ? TimeZoneInfo.FindSystemTimeZoneById(id)
+ : TimeZoneInfo.Local;
+
+ public string Time(DateTime? utc) => utc is { } value
+ ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(value, DateTimeKind.Utc), _zone).ToString("dd-MM-yyyy HH:mm", Danish)
+ : "–";
+
+ public string Ago(DateTime? utc)
+ {
+ if (utc is not { } value)
+ {
+ return "aldrig";
+ }
+
+ var age = DateTime.UtcNow - value;
+ return age.TotalMinutes < 2 ? "lige nu"
+ : age.TotalHours < 2 ? $"{age.TotalMinutes:0} min. siden"
+ : age.TotalDays < 2 ? $"{age.TotalHours:0} timer siden"
+ : $"{age.TotalDays:0} dage siden";
+ }
+
+ public static string Number(double value, int decimals = 0) => value.ToString("N" + decimals, Danish);
+
+ public static string SeverityName(Severity severity) => severity switch
+ {
+ Severity.Critical => "Kritisk",
+ Severity.High => "Høj",
+ Severity.Medium => "Middel",
+ Severity.Low => "Lav",
+ _ => "Info",
+ };
+
+ public static string SeverityClass(Severity? severity) => severity switch
+ {
+ Severity.Critical => "sev-critical",
+ Severity.High => "sev-high",
+ Severity.Medium => "sev-medium",
+ Severity.Low => "sev-low",
+ Severity.Info => "sev-info",
+ _ => "sev-ok",
+ };
+
+ public static string Release(string? productVersion) =>
+ SqlServerVersion.TryParse(productVersion, out var version) ? version.Release.DisplayName() : "Ukendt version";
+}
diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs
index 5d37520..797fb03 100644
--- a/src/SQLInsight.Web/Program.cs
+++ b/src/SQLInsight.Web/Program.cs
@@ -1,7 +1,89 @@
-// On-prem web UI (dashboard, findings, history). Placeholder until Fase 1.
+using Microsoft.AspNetCore.Authentication.Negotiate;
+using Microsoft.AspNetCore.Authorization;
+using SQLInsight.Repository;
+using SQLInsight.Web;
+using SQLInsight.Web.Components;
+
var builder = WebApplication.CreateBuilder(args);
+
+// Runs as a Windows service with its own Kestrel web server, or behind IIS (ASP.NET Core Module), or
+// as a systemd service on Linux. Each call is a no-op when not hosted that way.
+builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Web");
+builder.Services.AddSystemd();
+
+builder.Services.Configure(builder.Configuration.GetSection(WebOptions.SectionName));
+var web = builder.Configuration.GetSection(WebOptions.SectionName).Get() ?? new WebOptions();
+
+var connectionString = builder.Configuration["SQLInsight:Repository:ConnectionString"];
+if (string.IsNullOrWhiteSpace(connectionString))
+{
+ throw new InvalidOperationException("SQLInsight:Repository:ConnectionString mangler. Angiv forbindelsen til repository-databasen.");
+}
+
+builder.Services.AddSingleton(new InsightRepository(connectionString));
+builder.Services.AddSingleton();
+
+// Danish text (æ, ø, å) as characters rather than æ entities. HTML-significant characters are still encoded.
+builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
+builder.Services.AddRazorComponents();
+
+var windowsAuth = string.Equals(web.Authentication, "Windows", StringComparison.OrdinalIgnoreCase);
+if (windowsAuth)
+{
+ builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme).AddNegotiate();
+ builder.Services.AddAuthorization(options =>
+ {
+ var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser();
+ if (web.AllowedGroups.Count > 0)
+ {
+ policy.RequireAssertion(ctx => web.AllowedGroups.Any(ctx.User.IsInRole));
+ }
+
+ options.FallbackPolicy = policy.Build();
+ });
+}
+else if (!builder.Environment.IsDevelopment())
+{
+ throw new InvalidOperationException("SQLInsight:Web:Authentication = None er kun tilladt i Development. Web-UI'et viser oplysninger om jeres SQL Servere.");
+}
+
var app = builder.Build();
-app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
+if (!app.Environment.IsDevelopment())
+{
+ app.UseExceptionHandler("/fejl", createScopeForErrors: true);
+ app.UseHsts();
+}
+app.Use(async (context, next) =>
+{
+ context.Response.Headers.XContentTypeOptions = "nosniff";
+ context.Response.Headers.XFrameOptions = "DENY";
+ context.Response.Headers["Referrer-Policy"] = "no-referrer";
+ context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:";
+ await next();
+});
+
+if (windowsAuth)
+{
+ app.UseAuthentication();
+ app.UseAuthorization();
+}
+
+app.UseAntiforgery();
+app.MapStaticAssets();
+app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
+
+// Deadlock graph as .xdl, which SSMS opens as a graphical deadlock graph.
+app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository repository, CancellationToken ct) =>
+ await repository.GetDeadlockAsync(id, ct) is { } deadlock
+ ? Results.File(System.Text.Encoding.UTF8.GetBytes(deadlock.GraphXml), "application/xml", $"deadlock-{id}.xdl")
+ : Results.NotFound());
+
+app.MapRazorComponents();
+
+// The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader.
app.Run();
+
+/// Entry point, visible to integration tests.
+public partial class Program;
diff --git a/src/SQLInsight.Web/Properties/launchSettings.json b/src/SQLInsight.Web/Properties/launchSettings.json
deleted file mode 100644
index 9138b24..0000000
--- a/src/SQLInsight.Web/Properties/launchSettings.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/launchsettings.json",
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "http://localhost:5225",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "https://localhost:7224;http://localhost:5225",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
-}
diff --git a/src/SQLInsight.Web/SQLInsight.Web.csproj b/src/SQLInsight.Web/SQLInsight.Web.csproj
index 71968bf..213b2b1 100644
--- a/src/SQLInsight.Web/SQLInsight.Web.csproj
+++ b/src/SQLInsight.Web/SQLInsight.Web.csproj
@@ -4,5 +4,11 @@
+
+
+
+
+
+
diff --git a/src/SQLInsight.Web/WebOptions.cs b/src/SQLInsight.Web/WebOptions.cs
new file mode 100644
index 0000000..cb31b2f
--- /dev/null
+++ b/src/SQLInsight.Web/WebOptions.cs
@@ -0,0 +1,15 @@
+namespace SQLInsight.Web;
+
+public sealed class WebOptions
+{
+ public const string SectionName = "SQLInsight:Web";
+
+ /// Windows (Negotiate/Kerberos) or None. None is for development only.
+ public string Authentication { get; set; } = "Windows";
+
+ /// Windows groups allowed to use the UI (e.g. DOMAIN\SQL-Admins). Empty: any authenticated user.
+ public IList AllowedGroups { get; } = [];
+
+ /// Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.
+ public string? TimeZone { get; set; }
+}
diff --git a/src/SQLInsight.Web/appsettings.Development.json b/src/SQLInsight.Web/appsettings.Development.json
index 0c208ae..dfca35d 100644
--- a/src/SQLInsight.Web/appsettings.Development.json
+++ b/src/SQLInsight.Web/appsettings.Development.json
@@ -4,5 +4,10 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "SQLInsight": {
+ "Web": {
+ "Authentication": "None"
+ }
}
}
diff --git a/src/SQLInsight.Web/appsettings.json b/src/SQLInsight.Web/appsettings.json
index 10f68b8..df471aa 100644
--- a/src/SQLInsight.Web/appsettings.json
+++ b/src/SQLInsight.Web/appsettings.json
@@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "SQLInsight": {
+ "Repository": {
+ "ConnectionString": "Server=sqlmon01.eksempel.local;Database=SQLInsight;Integrated Security=true;Encrypt=true"
+ },
+ "Web": {
+ "Authentication": "Windows",
+ "AllowedGroups": [],
+ "TimeZone": "Europe/Copenhagen"
+ }
+ }
}
diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css
new file mode 100644
index 0000000..fb8f24b
--- /dev/null
+++ b/src/SQLInsight.Web/wwwroot/app.css
@@ -0,0 +1,156 @@
+/* SQLInsight Pro design system: light first, neutral surfaces, one accent, semantic severity colours. */
+@font-face {
+ font-family: "Inter";
+ src: url("fonts/inter-latin-wght-normal.woff2") format("woff2");
+ font-weight: 100 900; font-style: normal; font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: "Inter";
+ src: url("fonts/inter-latin-ext-wght-normal.woff2") format("woff2");
+ font-weight: 100 900; font-style: normal; font-display: swap;
+ unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+:root {
+ --bg: #f6f7f9;
+ --surface: #ffffff;
+ --surface-2: #f1f3f6;
+ --border: #e4e7ec;
+ --text: #101828;
+ --text-2: #475467;
+ --text-3: #98a2b3;
+ --accent: #0e7490; /* deep teal */
+ --accent-soft: #e0f2f6;
+ --accent-text: #0b5c72;
+ --critical: #d92d20; --critical-soft: #fee4e2;
+ --high: #e04f16; --high-soft: #ffead5;
+ --medium: #b54708; --medium-soft: #fef0c7;
+ --low: #1570ef; --low-soft: #d1e9ff;
+ --info: #667085; --info-soft: #f2f4f7;
+ --ok: #079455; --ok-soft: #dcfae6;
+ --radius: 12px;
+ --shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
+ --sidebar: 256px;
+ font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
+ font-feature-settings: "cv11", "ss01";
+ color-scheme: light;
+}
+
+:root[data-theme="dark"] {
+ --bg: #0c111d; --surface: #161b26; --surface-2: #1f2533; --border: #2a3140;
+ --text: #f5f5f6; --text-2: #cecfd2; --text-3: #85888e;
+ --accent: #22b8cf; --accent-soft: #0e2d36; --accent-text: #7dd9e8;
+ --critical-soft: #3b1512; --high-soft: #3a1d0d; --medium-soft: #3a2a07; --low-soft: #0f2744; --info-soft: #1f2533; --ok-soft: #0b2e1d;
+ --shadow: none;
+ color-scheme: dark;
+}
+
+* { box-sizing: border-box; }
+html { -webkit-text-size-adjust: 100%; }
+body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.55; }
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+.icon { width: 18px; height: 18px; flex: none; }
+
+/* Shell */
+.shell { display: grid; grid-template-columns: var(--sidebar) 1fr; min-height: 100vh; background: linear-gradient(to right, var(--surface) calc(var(--sidebar) - 1px), var(--border) calc(var(--sidebar) - 1px), var(--border) var(--sidebar), transparent var(--sidebar)); }
+.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 1.5rem; padding: 1.25rem 1rem; }
+.brand { display: flex; align-items: center; gap: .6rem; color: var(--text); font-size: 1.05rem; padding: 0 .5rem; }
+.brand:hover { text-decoration: none; }
+.brand strong { color: var(--accent); }
+.logo { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: linear-gradient(135deg, var(--accent), #155e75); color: #fff; font-weight: 700; font-size: .8rem; letter-spacing: .02em; }
+.nav { display: flex; flex-direction: column; gap: 2px; }
+.nav-label { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); padding: .9rem .75rem .35rem; }
+.nav a { display: flex; align-items: center; gap: .65rem; padding: .5rem .75rem; border-radius: 8px; color: var(--text-2); font-weight: 500; }
+.nav a:hover { background: var(--surface-2); text-decoration: none; color: var(--text); }
+.nav a.active { background: var(--accent-soft); color: var(--accent-text); }
+.support-card { margin-top: auto; padding: 1rem; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--border); display: flex; flex-direction: column; gap: .35rem; }
+.support-card .icon { color: var(--accent); width: 22px; height: 22px; }
+.support-card p { margin: 0; color: var(--text-2); font-size: .82rem; }
+.support-card a { font-weight: 600; font-size: .85rem; }
+.main { display: flex; flex-direction: column; min-width: 0; }
+.topbar { display: flex; justify-content: space-between; align-items: center; padding: .75rem 2rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 85%, transparent); backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 5; }
+.topbar-note { color: var(--text-3); font-size: .82rem; }
+.theme-toggle { display: inline-flex; align-items: center; gap: .4rem; border: 1px solid var(--border); background: var(--surface); color: var(--text-2); border-radius: 8px; padding: .35rem .7rem; font: inherit; font-size: .82rem; cursor: pointer; }
+.theme-toggle:hover { color: var(--text); background: var(--surface-2); }
+.page { width: 100%; max-width: 1320px; padding: 1.75rem 2rem 2rem; }
+.footer { color: var(--text-3); font-size: .8rem; padding: 1rem 2rem 2rem; }
+
+/* Typography */
+h1 { font-size: 1.6rem; line-height: 1.25; letter-spacing: -.02em; margin: .2rem 0 .35rem; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
+h2 { font-size: 1.02rem; letter-spacing: -.01em; margin: 0 0 .75rem; }
+h3 { font-size: .95rem; margin: 1.1rem 0 .3rem; }
+.lead { color: var(--text-2); margin: 0 0 1.5rem; }
+.muted { color: var(--text-3); }
+.crumbs { margin: 0; font-size: .85rem; color: var(--text-3); }
+.mono, pre, code { font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; font-size: .82rem; }
+
+/* Cards and sections */
+.card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.25rem 1.4rem; margin-bottom: 1.25rem; }
+.card-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .75rem; }
+.card-head h2 { margin: 0; }
+.card > table, .panel > table { margin: 0 -1.4rem -1.25rem; width: calc(100% + 2.8rem); border: 0; border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); box-shadow: none; }
+.section { margin-top: 1.75rem; }
+.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1.25rem; }
+.grid2 > .card, .grid2 > .panel { margin-bottom: 0; }
+
+/* KPI tiles */
+.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
+.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1rem 1.2rem; display: flex; gap: .9rem; align-items: center; }
+.kpi .kpi-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: var(--c-soft, var(--accent-soft)); color: var(--c, var(--accent)); }
+.kpi strong { display: block; font-size: 1.55rem; line-height: 1.1; letter-spacing: -.02em; }
+.kpi span { color: var(--text-2); font-size: .82rem; }
+
+/* Tables */
+table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
+th, td { text-align: left; padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }
+th { background: var(--surface-2); font-size: .74rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-2); font-weight: 600; }
+tbody tr:hover td { background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
+tr:last-child td { border-bottom: 0; }
+td .muted { font-size: .82rem; }
+.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.bad { color: var(--critical); font-weight: 600; }
+meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
+
+/* Severity */
+.sev-critical { --c: var(--critical); --c-soft: var(--critical-soft); }
+.sev-high { --c: var(--high); --c-soft: var(--high-soft); }
+.sev-medium { --c: var(--medium); --c-soft: var(--medium-soft); }
+.sev-low { --c: var(--low); --c-soft: var(--low-soft); }
+.sev-info { --c: var(--info); --c-soft: var(--info-soft); }
+.sev-ok { --c: var(--ok); --c-soft: var(--ok-soft); }
+.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .12rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; white-space: nowrap; color: var(--c, var(--info)); background: var(--c-soft, var(--info-soft)); }
+.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
+.counts { display: flex; gap: .3rem; flex-wrap: wrap; }
+
+/* Charts */
+.chart { margin: 0; }
+.chart svg { width: 100%; height: 90px; display: block; }
+.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; }
+.chart .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; stroke-dasharray: 3 4; }
+.chart figcaption { display: flex; justify-content: space-between; gap: 1rem; font-size: .82rem; color: var(--text-2); margin-top: .5rem; }
+.chart figcaption span:first-child { font-weight: 600; color: var(--text); }
+
+/* Alerts */
+.alert { border: 1px solid var(--c, var(--critical)); background: var(--c-soft, var(--critical-soft)); border-radius: var(--radius); padding: .85rem 1rem; margin: 0 0 1.25rem; }
+.alert.warn { --c: var(--medium); --c-soft: var(--medium-soft); }
+.alert pre { white-space: pre-wrap; margin: .5rem 0 0; }
+
+/* Finding detail */
+.evidence { box-shadow: none; }
+.evidence th { width: 38%; text-transform: none; letter-spacing: 0; font-size: .85rem; background: transparent; color: var(--text-2); font-weight: 500; }
+.recommendation { border-left: 4px solid var(--ok); }
+.script { position: relative; }
+.script pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: .5rem 0 0; }
+.copy { position: absolute; top: .5rem; right: .5rem; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 6px; padding: .2rem .65rem; font: inherit; font-size: .8rem; cursor: pointer; }
+.empty { background: var(--surface); border: 1px dashed var(--border); border-radius: var(--radius); padding: 1.25rem; color: var(--text-2); }
+
+@media (max-width: 900px) {
+ .shell { grid-template-columns: 1fr; }
+ .shell { background: none; }
+ .sidebar { position: static; height: auto; background: var(--surface); border-bottom: 1px solid var(--border); }
+ .support-card { display: none; }
+ .page, .topbar { padding-left: 1rem; padding-right: 1rem; }
+ .grid2 { grid-template-columns: 1fr; }
+}
diff --git a/src/SQLInsight.Web/wwwroot/app.js b/src/SQLInsight.Web/wwwroot/app.js
new file mode 100644
index 0000000..e34ffab
--- /dev/null
+++ b/src/SQLInsight.Web/wwwroot/app.js
@@ -0,0 +1,22 @@
+// Copy buttons for fix scripts and the theme toggle. Everything else is server-rendered.
+document.addEventListener('click', async (event) => {
+ const copy = event.target.closest('button[data-copy]');
+ if (copy) {
+ const source = document.getElementById(copy.dataset.copy);
+ try {
+ await navigator.clipboard.writeText(source.textContent);
+ copy.textContent = 'Kopieret';
+ } catch {
+ copy.textContent = 'Markér og kopiér manuelt';
+ }
+ setTimeout(() => { copy.textContent = 'Kopiér'; }, 2000);
+ return;
+ }
+
+ if (event.target.closest('[data-theme-toggle]')) {
+ const dark = document.documentElement.dataset.theme !== 'dark';
+ if (dark) document.documentElement.dataset.theme = 'dark';
+ else delete document.documentElement.dataset.theme;
+ try { localStorage.setItem('sqlinsight-theme', dark ? 'dark' : 'light'); } catch { /* ignore */ }
+ }
+});
diff --git a/src/SQLInsight.Web/wwwroot/favicon.svg b/src/SQLInsight.Web/wwwroot/favicon.svg
new file mode 100644
index 0000000..54199d9
--- /dev/null
+++ b/src/SQLInsight.Web/wwwroot/favicon.svg
@@ -0,0 +1 @@
+SI
diff --git a/src/SQLInsight.Web/wwwroot/fonts/Inter-OFL.txt b/src/SQLInsight.Web/wwwroot/fonts/Inter-OFL.txt
new file mode 100644
index 0000000..40589da
--- /dev/null
+++ b/src/SQLInsight.Web/wwwroot/fonts/Inter-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/SQLInsight.Web/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 b/src/SQLInsight.Web/wwwroot/fonts/inter-latin-ext-wght-normal.woff2
new file mode 100644
index 0000000..479d010
Binary files /dev/null and b/src/SQLInsight.Web/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 differ
diff --git a/src/SQLInsight.Web/wwwroot/fonts/inter-latin-wght-normal.woff2 b/src/SQLInsight.Web/wwwroot/fonts/inter-latin-wght-normal.woff2
new file mode 100644
index 0000000..d15208d
Binary files /dev/null and b/src/SQLInsight.Web/wwwroot/fonts/inter-latin-wght-normal.woff2 differ
diff --git a/src/SQLInsight.Web/wwwroot/theme.js b/src/SQLInsight.Web/wwwroot/theme.js
new file mode 100644
index 0000000..eb022f8
--- /dev/null
+++ b/src/SQLInsight.Web/wwwroot/theme.js
@@ -0,0 +1,3 @@
+try {
+ if (localStorage.getItem('sqlinsight-theme') === 'dark') document.documentElement.dataset.theme = 'dark';
+} catch { /* storage blocked: stay light */ }
diff --git a/tests/SQLInsight.IntegrationTests/AgentPipelineTests.cs b/tests/SQLInsight.IntegrationTests/AgentPipelineTests.cs
index 369c1e6..8b78c10 100644
--- a/tests/SQLInsight.IntegrationTests/AgentPipelineTests.cs
+++ b/tests/SQLInsight.IntegrationTests/AgentPipelineTests.cs
@@ -81,6 +81,24 @@ public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime
Assert.NotEmpty(await _repository.GetTopWaitsAsync(instance.InstanceId, start.AddHours(-1)));
}
+ [Fact]
+ public async Task Restarted_agent_does_not_report_or_store_deadlocks_again()
+ {
+ var now = DateTimeOffset.UtcNow;
+ await Monitor("restart").RunCycleAsync(now, CancellationToken.None);
+ var instance = await _repository.GetInstanceAsync("restart");
+ var stored = (await _repository.GetDeadlocksAsync(instance!.InstanceId, now.AddDays(-2))).Count;
+
+ // A new monitor is what the agent has after a restart: no memory of where it stopped.
+ var results = await Monitor("restart").RunCycleAsync(now.AddMinutes(1), CancellationToken.None);
+
+ Assert.Equal(stored, (await _repository.GetDeadlocksAsync(instance.InstanceId, now.AddDays(-2))).Count);
+ if (stored > 0)
+ {
+ Assert.Equal(RuleOutcome.Passed, results!.Single(r => r.RuleId == "SQLI-DLK-001").Outcome);
+ }
+ }
+
[Fact]
public async Task Unreachable_instance_is_recorded_as_failing()
{
diff --git a/tests/SQLInsight.IntegrationTests/CollectorTests.cs b/tests/SQLInsight.IntegrationTests/CollectorTests.cs
index 98015d5..bdb6741 100644
--- a/tests/SQLInsight.IntegrationTests/CollectorTests.cs
+++ b/tests/SQLInsight.IntegrationTests/CollectorTests.cs
@@ -119,6 +119,8 @@ public class CollectorTests(SqlServerFixture sql)
// The ring buffer gets its first record about a minute after startup.
Assert.All(cpu.Samples, s => Assert.InRange(s.SqlServerPercent + s.IdlePercent, 0, 100));
Assert.Equal(cpu.Samples.OrderBy(s => s.Time), cpu.Samples);
+ Assert.Equal("Linux", cpu.HostPlatform); // test containers run SQL Server on Linux
+ Assert.False(cpu.OtherProcessesMeasurable);
}
[Fact]
diff --git a/tests/SQLInsight.IntegrationTests/RepositoryTests.cs b/tests/SQLInsight.IntegrationTests/RepositoryTests.cs
index 2108647..dd59ffb 100644
--- a/tests/SQLInsight.IntegrationTests/RepositoryTests.cs
+++ b/tests/SQLInsight.IntegrationTests/RepositoryTests.cs
@@ -216,10 +216,12 @@ public class RepositoryTests(SqlServerFixture sql) : IAsyncLifetime
var config = new ConfigurationSnapshot { CapturedAt = at, Options = [new ConfigurationOption("max degree of parallelism", 8, 8)] };
await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks);
+ await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks); // duplicates are ignored
await _repository.SaveCollectedAsync(id, "blocking", blocking);
await _repository.SaveCollectedAsync(id, "configuration", config);
var deadlock = Assert.Single(await _repository.GetDeadlocksAsync(id, at.AddHours(-1)));
+ Assert.Equal(InsightRepository.UtcMilliseconds(at), (await _repository.GetLatestDeadlockTimeAsync(id))!.Value.UtcDateTime);
Assert.True(Assert.Single(deadlock.Processes).IsVictim);
Assert.Equal(" ", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml);
diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj
index f08f1f4..78581d7 100644
--- a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj
+++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj
@@ -4,6 +4,7 @@
+
@@ -11,6 +12,7 @@
+
diff --git a/tests/SQLInsight.IntegrationTests/WebTests.cs b/tests/SQLInsight.IntegrationTests/WebTests.cs
new file mode 100644
index 0000000..c08d5eb
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/WebTests.cs
@@ -0,0 +1,160 @@
+using System.Net;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Findings;
+using SQLInsight.Core.Instances;
+using SQLInsight.Repository;
+using SQLInsight.Rules.Engine;
+
+namespace SQLInsight.IntegrationTests;
+
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class WebTests(SqlServerFixture sql) : IAsyncLifetime
+{
+ private readonly string _database = "SQLInsightWeb_" + Guid.NewGuid().ToString("N")[..8];
+ private WebApplicationFactory _factory = null!;
+ private InsightRepository _repository = null!;
+ private long _findingId;
+ private long _deadlockId;
+
+ public async Task InitializeAsync()
+ {
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
+ var connectionString = sql.DatabaseConnectionString(_database);
+ _repository = new InsightRepository(connectionString);
+ await _repository.MigrateAsync();
+
+ var info = new InstanceInfo
+ {
+ ServerName = "SQL01",
+ Version = SqlServerVersion.Parse("15.0.4355.3"),
+ ProductLevel = "RTM",
+ ProductUpdateLevel = "CU28",
+ Edition = "Standard Edition",
+ EngineEdition = EngineEdition.Standard,
+ };
+ var id = await _repository.UpsertInstanceAsync("prod-sql01", info, "SqlClient", false, DateTimeOffset.UtcNow);
+ await _repository.SaveRuleResultsAsync(id,
+ [
+ new RuleResult
+ {
+ RuleId = "SQLI-CFG-001",
+ RuleTitle = "max server memory er ikke sat",
+ Outcome = RuleOutcome.Findings,
+ Findings =
+ [
+ new Finding
+ {
+ RuleId = "SQLI-CFG-001",
+ Severity = Severity.High,
+ Title = "max server memory er ikke sat",
+ Explanation = "SQL Server må bruge al hukommelse ",
+ Recommendation = "Sæt max server memory.",
+ Evidence = [new Evidence("max server memory (MB)", "2147483647")],
+ FixScript = "EXEC sys.sp_configure N'max server memory (MB)', 58983;",
+ References = [new Reference("Server memory", new Uri("https://learn.microsoft.com/sql/"))],
+ },
+ ],
+ },
+ new RuleResult { RuleId = "SQLI-DB-011", RuleTitle = "VLF'er", Outcome = RuleOutcome.NotMeasurable, Reason = "Kræver SQL Server 2016 SP2" },
+ ], DateTimeOffset.UtcNow);
+ await _repository.SaveCollectedAsync(id, "deadlocks", new DeadlockSnapshot
+ {
+ CapturedAt = DateTimeOffset.UtcNow,
+ NewDeadlocks = [new DeadlockEvent { Time = DateTimeOffset.UtcNow, GraphXml = " ", Processes = [new DeadlockProcess { Id = "p1", IsVictim = true, SessionId = 60, ClientApp = "Sales.Api" }] }],
+ });
+ _findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId;
+ _deadlockId = (await _repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddHours(-1))).Single().DeadlockId;
+
+ _factory = new WebApplicationFactory().WithWebHostBuilder(b =>
+ {
+ b.UseEnvironment("Development");
+ b.UseSetting("SQLInsight:Repository:ConnectionString", connectionString);
+ b.UseSetting("SQLInsight:Web:Authentication", "None");
+ b.UseSetting("SQLInsight:Web:TimeZone", "Europe/Copenhagen");
+ });
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _factory.DisposeAsync();
+ await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
+ }
+
+ [Fact]
+ public async Task Overview_lists_instances_and_urgent_findings()
+ {
+ var html = await _factory.CreateClient().GetStringAsync("/");
+
+ Assert.Contains("prod-sql01", html, StringComparison.Ordinal);
+ Assert.Contains("SQL Server 2019", html, StringComparison.Ordinal);
+ Assert.Contains("max server memory er ikke sat", html, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Instance_page_shows_findings_and_not_measurable_rules()
+ {
+ var html = await _factory.CreateClient().GetStringAsync("/instanser/prod-sql01");
+
+ Assert.Contains("Aktive fund", html, StringComparison.Ordinal);
+ Assert.Contains("SQLI-DB-011", html, StringComparison.Ordinal);
+ Assert.Contains("Kræver SQL Server 2016 SP2", html, StringComparison.Ordinal);
+ Assert.Contains($"/deadlocks/{_deadlockId}.xdl", html, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded()
+ {
+ var html = await _factory.CreateClient().GetStringAsync($"/fund/{_findingId}");
+
+ Assert.Contains("Hvorfor er det et problem?", html, StringComparison.Ordinal);
+ Assert.Contains("2147483647", html, StringComparison.Ordinal);
+ Assert.Contains("sp_configure", html, StringComparison.Ordinal);
+ Assert.DoesNotContain("", html, StringComparison.Ordinal);
+ Assert.Contains("<script>", html, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Deadlock_graph_downloads_as_xdl()
+ {
+ var response = await _factory.CreateClient().GetAsync($"/deadlocks/{_deadlockId}.xdl");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal($"deadlock-{_deadlockId}.xdl", response.Content.Headers.ContentDisposition?.FileName);
+ Assert.StartsWith("", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Security_headers_are_set()
+ {
+ var response = await _factory.CreateClient().GetAsync("/");
+
+ Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal);
+ Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
+ }
+
+ [Fact]
+ public async Task Unknown_instance_and_finding_are_handled()
+ {
+ var client = _factory.CreateClient();
+
+ Assert.Contains("Ukendt instans", await client.GetStringAsync("/instanser/findes-ikke"), StringComparison.Ordinal);
+ Assert.Contains("Ukendt fund", await client.GetStringAsync("/fund/999999"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Refuses_to_run_without_authentication_outside_development()
+ {
+ using var production = new WebApplicationFactory().WithWebHostBuilder(b =>
+ {
+ b.UseEnvironment("Production");
+ b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
+ b.UseSetting("SQLInsight:Web:Authentication", "None");
+ });
+
+ var ex = Assert.ThrowsAny(() => production.CreateClient());
+ Assert.Contains("kun tilladt i Development", ex.ToString(), StringComparison.Ordinal);
+ }
+}
diff --git a/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs b/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs
index e8d1cdf..1af6072 100644
--- a/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs
+++ b/tests/SQLInsight.Rules.Tests/PerformanceRuleTests.cs
@@ -96,9 +96,10 @@ public class PerformanceRuleTests
}
}
- private static CpuUtilization Cpu(int sql, int idle, int minutes = 15) => new()
+ private static CpuUtilization Cpu(int sql, int idle, int minutes = 15, string platform = "Windows") => new()
{
CapturedAt = Now,
+ HostPlatform = platform,
Samples = Enumerable.Range(0, minutes).Select(i => new CpuSample(Now.AddMinutes(i - minutes), sql, idle)).ToList(),
};
@@ -110,6 +111,13 @@ public class PerformanceRuleTests
Assert.Equal(RuleOutcome.Passed, Run(new CpuRule(), With(Cpu(sql: 30, idle: 65))).Outcome);
}
+ [Fact]
+ public void Other_processes_are_not_judged_on_linux()
+ {
+ // SystemIdle reads near 0 in Linux containers, which would look like 98 % CPU used by others.
+ Assert.Equal(RuleOutcome.Passed, Run(new CpuRule(), With(Cpu(sql: 2, idle: 0, platform: "Linux"))).Outcome);
+ }
+
[Fact]
public void Cpu_needs_enough_history()
{