Fase 1: on-prem web UI (IIS or Windows service), light design system
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run

- ASP.NET Core Blazor static SSR: overview with KPI tiles, instance page
  (findings, CPU/batch/PLE trends, top waits, file latency, blocking,
  deadlocks with .xdl download, not-measurable rules, resolved), finding
  detail with explanation, evidence, fix script and references
- Light-first design (sidebar, cards, self-hosted Inter, teal accent),
  optional dark theme via toggle; strict CSP, no inline styles
- Windows authentication (Negotiate/IIS) with optional group restriction;
  refuses to run without authentication outside Development
- Hosting: IIS (in-process ASP.NET Core Module), Windows service with
  Kestrel, or systemd; web reads the repository with db_datareader only
- docs/installation.md for repository, permissions, agent and web
- Fix: CPU used by other processes is not derived on Linux (SystemIdle is
  unreliable there); deadlocks are not re-reported or stored twice after
  an agent restart; Dapper sends datetime2 so time comparisons are exact

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 06:58:51 +00:00
parent 005a4f9663
commit 5bb1c37fef
No known key found for this signature in database
42 changed files with 1453 additions and 39 deletions

View file

@ -10,6 +10,8 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<!-- Test --> <!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />

108
docs/installation.md Normal file
View file

@ -0,0 +1,108 @@
# Installation
SQLInsight Pro består af to programmer, som begge installeres hos jer (on-prem):
| Del | Hvad den gør | Kører som |
|---|---|---|
| **SQLInsight Agent** | Overvåger SQL Server-instanserne og gemmer fund i repository-databasen | Windows-tjeneste (eller systemd på Linux) |
| **SQLInsight Web** | Lokalt websted, der viser fund, trends og anbefalinger | IIS-websted **eller** selvstændig Windows-tjeneste med indbygget webserver (Kestrel) |
Begge bruger en **repository-database** på en SQL Server 2016+-instans, der helst ikke er en af de overvågede.
> Installationstrinene er skrevet ud fra Microsofts dokumentation for ASP.NET Core-hosting. De er endnu ikke gennemtestet på en ren Windows Server. Meld gerne afvigelser tilbage som issue.
## 1. Forudsætninger
- Windows Server 2016 eller nyere (eller Linux til agenten)
- [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) på agent-serveren; **ASP.NET Core Hosting Bundle** på webserveren, hvis IIS bruges
- To tjenestekonti, gerne gMSA: én til agenten og én til web-UI'et
- Til SQL Server 2000: en Windows-agent med en ODBC-driver, der understøtter 2000 (se [legacy-versions.md](legacy-versions.md))
## 2. Repository-databasen
```sql
CREATE DATABASE SQLInsight;
GO
USE SQLInsight;
CREATE LOGIN [DOMÆNE\svc-sqlinsight-agent$] FROM WINDOWS;
CREATE USER [DOMÆNE\svc-sqlinsight-agent$];
ALTER ROLE db_owner ADD MEMBER [DOMÆNE\svc-sqlinsight-agent$]; -- agenten opretter og opgraderer skemaet
CREATE LOGIN [DOMÆNE\svc-sqlinsight-web$] FROM WINDOWS;
CREATE USER [DOMÆNE\svc-sqlinsight-web$];
ALTER ROLE db_datareader ADD MEMBER [DOMÆNE\svc-sqlinsight-web$]; -- web-UI'et læser kun
```
Agenten opretter tabellerne selv ved første start.
## 3. Rettigheder på de overvågede instanser
Kør [`db/permissions/grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql) på hver instans efter at have oprettet agentens login. Agenten bliver aldrig sysadmin.
## 4. Agenten som Windows-tjeneste
```powershell
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained false -o C:\Program Files\SQLInsight\Agent
```
Ret `C:\Program Files\SQLInsight\Agent\appsettings.json`: `SQLInsight:Repository:ConnectionString` og listen `SQLInsight:Instances`. Opret derefter tjenesten:
```powershell
sc.exe create "SQLInsight Agent" binPath= "\"C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe\"" obj= "DOMÆNE\svc-sqlinsight-agent$" start= delayed-auto
sc.exe description "SQLInsight Agent" "Overvåger SQL Server og gemmer fund i SQLInsight-repositoriet."
sc.exe start "SQLInsight Agent"
```
For en gMSA angives ingen adgangskode. Loggen kan ses i Windows' Event Viewer under *Application*.
## 5a. Web-UI'et på IIS
1. Installér **ASP.NET Core Hosting Bundle** for .NET 10, og kør `iisreset`.
2. Publicér:
```powershell
dotnet publish src/SQLInsight.Web -c Release -o C:\inetpub\SQLInsight
```
Publiceringen danner en `web.config` med ASP.NET Core Module (in-process).
3. Opret en **application pool** med *.NET CLR version = No Managed Code*, og sæt dens identitet til `DOMÆNE\svc-sqlinsight-web$`.
4. Opret et websted, der peger på `C:\inetpub\SQLInsight`, med en **HTTPS-binding** og et certifikat fra jeres interne CA.
5. Under *Authentication*: slå **Windows Authentication** til og **Anonymous Authentication** fra.
6. Ret `appsettings.json`: `SQLInsight:Repository:ConnectionString` (Integrated Security, så app pool-identiteten bruges), eventuelt `SQLInsight:Web:AllowedGroups`, fx `["DOMÆNE\\SQL-Admins"]`.
## 5b. Web-UI'et som selvstændig Windows-tjeneste (uden IIS)
Web-UI'et har sin egen webserver (Kestrel) og kan køre som tjeneste på en server uden IIS.
```powershell
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained false -o "C:\Program Files\SQLInsight\Web"
```
Tilføj et HTTPS-endpoint i `appsettings.json` med et certifikat fra maskinens certifikatlager:
```json
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://*:8443",
"Certificate": { "Subject": "sqlinsight.domæne.local", "Store": "My", "Location": "LocalMachine" }
}
}
}
```
Opret tjenesten og en SPN, så Kerberos-login virker:
```powershell
sc.exe create "SQLInsight Web" binPath= "\"C:\Program Files\SQLInsight\Web\SQLInsight.Web.exe\"" obj= "DOMÆNE\svc-sqlinsight-web$" start= delayed-auto
setspn -S HTTP/sqlinsight.domæne.local DOMÆNE\svc-sqlinsight-web$
sc.exe start "SQLInsight Web"
```
Åbn porten i Windows Firewall for de brugere, der skal se UI'et.
## Linux (systemd)
Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux kræver en Kerberos keytab; se Microsofts dokumentation for Negotiate-autentificering på Linux.
## Opdatering
Stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv.

View file

@ -102,7 +102,11 @@ public sealed partial class InstanceMonitor(
try try
{ {
_previous.TryGetValue(collector.Name, out var previous); if (!_previous.TryGetValue(collector.Name, out var previous))
{
previous = await SeedAsync(collector, instanceId, now, cancellationToken).ConfigureAwait(false);
}
var result = await collector.CollectAsync(new CollectorContext(opened.Connection, info, capabilities, now, previous, cancellationToken)).ConfigureAwait(false); var result = await collector.CollectAsync(new CollectorContext(opened.Connection, info, capabilities, now, previous, cancellationToken)).ConfigureAwait(false);
_previous[collector.Name] = result; _previous[collector.Name] = result;
_data.Set(collector.DataType, result); _data.Set(collector.DataType, result);
@ -116,6 +120,21 @@ public sealed partial class InstanceMonitor(
} }
} }
/// <summary>
/// State a collector would otherwise lose when the agent restarts. Deadlocks continue after the newest one
/// already stored; the others simply start over (cumulative collectors need one extra sample).
/// </summary>
private async Task<object?> SeedAsync(ICollector collector, int instanceId, DateTimeOffset now, CancellationToken cancellationToken)
{
if (collector is not Collectors.Concurrency.DeadlockCollector)
{
return null;
}
var latest = await repository.GetLatestDeadlockTimeAsync(instanceId, cancellationToken).ConfigureAwait(false);
return latest is null ? null : new Core.Data.DeadlockSnapshot { CapturedAt = now, NewDeadlocks = [], LastEventTime = latest };
}
[LoggerMessage(Level = LogLevel.Error, Message = "{Instance}: could not connect")] [LoggerMessage(Level = LogLevel.Error, Message = "{Instance}: could not connect")]
private static partial void LogConnectFailed(ILogger logger, Exception exception, string instance); private static partial void LogConnectFailed(ILogger logger, Exception exception, string instance);

View file

@ -37,7 +37,10 @@ public sealed class CpuCollector : Collector<CpuUtilization>
context.CancellationToken).ConfigureAwait(false); context.CancellationToken).ConfigureAwait(false);
samples.Reverse(); samples.Reverse();
return new CpuUtilization { CapturedAt = context.Now, Samples = samples }; var platform = context.Instance.Version.Major >= 14
? (await Db.QueryAsync(context.Connection, "SELECT host_platform FROM sys.dm_os_host_info", r => r.NullableString(0), context.CancellationToken).ConfigureAwait(false)).FirstOrDefault()
: "Windows";
return new CpuUtilization { CapturedAt = context.Now, Samples = samples, HostPlatform = platform };
} }
/// <summary> /// <summary>

View file

@ -8,6 +8,14 @@ public sealed record CpuUtilization
{ {
public required DateTimeOffset CapturedAt { get; init; } public required DateTimeOffset CapturedAt { get; init; }
/// <summary>
/// Windows or Linux (2017+). On Linux the ring buffer's SystemIdle is not reliable (it reads near 0 in
/// containers), so CPU used by other processes cannot be derived.
/// </summary>
public string? HostPlatform { get; init; }
public bool OtherProcessesMeasurable => !string.Equals(HostPlatform, "Linux", StringComparison.OrdinalIgnoreCase);
/// <summary>One sample per minute, oldest first.</summary> /// <summary>One sample per minute, oldest first.</summary>
public required IReadOnlyList<CpuSample> Samples { get; init; } public required IReadOnlyList<CpuSample> Samples { get; init; }
} }

View file

@ -14,6 +14,15 @@ namespace SQLInsight.Repository;
/// </summary> /// </summary>
public sealed partial class InsightRepository(string connectionString) public sealed partial class InsightRepository(string connectionString)
{ {
static InsightRepository()
{
// Dapper sends DateTime as the legacy datetime type (≈3 ms steps). Since SQL Server 2016, comparing
// datetime with datetime2 uses the exact binary value, so .127 no longer equals .1266667. All columns are
// datetime2, so send datetime2.
SqlMapper.AddTypeMap(typeof(DateTime), System.Data.DbType.DateTime2);
SqlMapper.AddTypeMap(typeof(DateTime?), System.Data.DbType.DateTime2);
}
// PascalCase: OPENJSON ... WITH matches property names case-sensitively. // PascalCase: OPENJSON ... WITH matches property names case-sensitively.
internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.General); internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.General);
@ -36,6 +45,13 @@ public sealed partial class InsightRepository(string connectionString)
return new DateTime(utc.Ticks - (utc.Ticks % TimeSpan.TicksPerSecond), DateTimeKind.Utc); return new DateTime(utc.Ticks - (utc.Ticks % TimeSpan.TicksPerSecond), DateTimeKind.Utc);
} }
/// <summary>UTC truncated to milliseconds, matching datetime2(3) (deadlock times must survive a round trip exactly).</summary>
internal static DateTime UtcMilliseconds(DateTimeOffset value)
{
var utc = value.UtcDateTime;
return new DateTime(utc.Ticks - (utc.Ticks % TimeSpan.TicksPerMillisecond), DateTimeKind.Utc);
}
/// <summary>Registers a successful contact with an instance and returns its id.</summary> /// <summary>Registers a successful contact with an instance and returns its id.</summary>
public async Task<int> UpsertInstanceAsync( public async Task<int> UpsertInstanceAsync(
string name, InstanceInfo info, string provider, bool usedLegacyFallback, DateTimeOffset seenAt, CancellationToken cancellationToken = default) string name, InstanceInfo info, string provider, bool usedLegacyFallback, DateTimeOffset seenAt, CancellationToken cancellationToken = default)
@ -240,11 +256,10 @@ public sealed partial class InsightRepository(string connectionString)
} }
private static Task<int> SaveCpuAsync(SqlConnection connection, int instanceId, CpuUtilization cpu) => private static Task<int> SaveCpuAsync(SqlConnection connection, int instanceId, CpuUtilization cpu) =>
InsertMetricsAsync(connection, instanceId, cpu.Samples.SelectMany(s => new[] InsertMetricsAsync(connection, instanceId, cpu.Samples.SelectMany(s =>
{ cpu.OtherProcessesMeasurable
(Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent), ? new[] { (Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent), (Metrics.CpuOther, s.Time, (double)s.OtherProcessesPercent) }
(Metrics.CpuOther, s.Time, (double)s.OtherProcessesPercent), : new[] { (Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent) }));
}));
/// <summary>Blocking shorter than this is normal and not stored.</summary> /// <summary>Blocking shorter than this is normal and not stored.</summary>
public const long BlockingThresholdMs = 5000; public const long BlockingThresholdMs = 5000;
@ -298,12 +313,13 @@ public sealed partial class InsightRepository(string connectionString)
foreach (var deadlock in snapshot.NewDeadlocks) foreach (var deadlock in snapshot.NewDeadlocks)
{ {
await connection.ExecuteAsync(""" await connection.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM insight.Deadlocks WHERE InstanceId = @instanceId AND OccurredAt = @at AND GraphXml = @graph)
INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml)
VALUES (@instanceId, @at, @processes, @graph); VALUES (@instanceId, @at, @processes, @graph);
""", new """, new
{ {
instanceId, instanceId,
at = Utc(deadlock.Time), at = UtcMilliseconds(deadlock.Time),
processes = JsonSerializer.Serialize(deadlock.Processes, Json), processes = JsonSerializer.Serialize(deadlock.Processes, Json),
graph = deadlock.GraphXml, graph = deadlock.GraphXml,
}).ConfigureAwait(false); }).ConfigureAwait(false);
@ -311,6 +327,21 @@ public sealed partial class InsightRepository(string connectionString)
} }
} }
public sealed partial class InsightRepository
{
/// <summary>
/// The newest stored deadlock for an instance, so a restarted agent continues after it instead of reporting
/// deadlocks it has already stored.
/// </summary>
public async Task<DateTimeOffset?> GetLatestDeadlockTimeAsync(int instanceId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var latest = await connection.ExecuteScalarAsync<DateTime?>(
"SELECT MAX(OccurredAt) FROM insight.Deadlocks WHERE InstanceId = @instanceId;", new { instanceId }).ConfigureAwait(false);
return latest is { } value ? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc)) : null;
}
}
public static class Metrics public static class Metrics
{ {
public const string CpuSqlServer = "cpu.sqlserver"; public const string CpuSqlServer = "cpu.sqlserver";

View file

@ -10,6 +10,9 @@
<PackageReference Include="Dapper" /> <PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Data.SqlClient" /> <PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.IntegrationTests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" /> <EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup> </ItemGroup>

View file

@ -97,14 +97,15 @@ public sealed class CpuRule : Rule
throw new DataUnavailableException("CPU-historikken har endnu for få målinger (scheduler monitor skriver én pr. minut)."); throw new DataUnavailableException("CPU-historikken har endnu for få målinger (scheduler monitor skriver én pr. minut).");
} }
var cpu = context.Data.Get<CpuUtilization>();
var sql = samples.Average(s => s.SqlServerPercent); var sql = samples.Average(s => s.SqlServerPercent);
var other = samples.Average(s => s.OtherProcessesPercent); var other = cpu.OtherProcessesMeasurable ? samples.Average(s => s.OtherProcessesPercent) : 0;
var findings = new List<Finding>(); var findings = new List<Finding>();
var evidence = new[] var evidence = new[]
{ {
new Evidence("Periode", $"{samples.Count} min"), new Evidence("Periode", $"{samples.Count} min"),
new Evidence("SQL Server gns./maks.", $"{Format.Number(sql)} % / {samples.Max(s => s.SqlServerPercent)} %"), new Evidence("SQL Server gns./maks.", $"{Format.Number(sql)} % / {samples.Max(s => s.SqlServerPercent)} %"),
new Evidence("Andre processer gns.", $"{Format.Number(other)} %"), new Evidence("Andre processer gns.", cpu.OtherProcessesMeasurable ? $"{Format.Number(other)} %" : "kan ikke måles på Linux"),
}; };
if (sql >= 75) if (sql >= 75)

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="preload" href="@Assets["fonts/inter-latin-wght-normal.woff2"]" as="font" type="font/woff2" crossorigin />
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
@* Applies a saved theme before first paint. Light is the default; dark only when chosen. *@
<script src="@Assets["theme.js"]"></script>
<HeadOutlet />
</head>
<body>
<Routes />
<script src="@Assets["app.js"]"></script>
</body>
</html>

View file

@ -0,0 +1,40 @@
@inherits LayoutComponentBase
<div class="shell">
<aside class="sidebar">
<a class="brand" href="/">
<span class="logo" aria-hidden="true">SI</span>
<span>SQLInsight <strong>Pro</strong></span>
</a>
<nav class="nav">
<span class="nav-label">Overvågning</span>
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
<span class="nav-label">Community</span>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro" rel="noopener"><Icon Name="users" /> Projekt og forum</a>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro/src/branch/main/docs" rel="noopener"><Icon Name="book" /> Dokumentation</a>
</nav>
<div class="support-card">
<Icon Name="support" />
<strong>Brug for en ekspert?</strong>
<p>Professional og Enterprise giver prioriteret mail- og telefonsupport fra erfarne SQL Server-folk.</p>
<a href="https://git.seosoft.dk/seo/sqlinsight-pro#forretningsmodel" rel="noopener">Se supportaftaler</a>
</div>
</aside>
<div class="main">
<header class="topbar">
<span class="topbar-note">Gratis og open source · kører on-prem</span>
<button type="button" class="theme-toggle" data-theme-toggle title="Skift mellem lyst og mørkt tema">
<Icon Name="moon" /> <span>Tema</span>
</button>
</header>
<main class="page">
@Body
</main>
<footer class="footer">SQLInsight Pro · MIT-licens · <a href="https://git.seosoft.dk/seo/sqlinsight-pro">kildekode</a></footer>
</div>
</div>

View file

@ -0,0 +1,16 @@
@page "/fund"
@inject InsightRepository Repository
<PageTitle>Alle fund · SQLInsight Pro</PageTitle>
<h1>Alle aktive fund</h1>
<p class="lead">Sorteret efter alvor på tværs af alle instanser.</p>
<section class="card">
<FindingList Findings="_findings" ShowInstance="true" />
</section>
@code {
private IReadOnlyList<StoredFinding> _findings = [];
protected override async Task OnInitializedAsync() => _findings = await Repository.GetActiveFindingsAsync();
}

View file

@ -0,0 +1,5 @@
@page "/fejl"
<PageTitle>Fejl · SQLInsight Pro</PageTitle>
<h1>Der opstod en fejl</h1>
<p>Fejlen er logget. Tjek, at repository-databasen kan nås.</p>

View file

@ -0,0 +1,76 @@
@page "/fund/{Id:long}"
@inject InsightRepository Repository
@inject Display Display
@if (_finding is null)
{
<PageTitle>Ukendt fund · SQLInsight Pro</PageTitle>
<h1>Ukendt fund</h1>
return;
}
<PageTitle>@_finding.Title · SQLInsight Pro</PageTitle>
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_finding.InstanceName)">@_finding.InstanceName</a> / @_finding.RuleId</p>
<h1><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
<p class="lead">
@if (_finding.ResolvedAt is { } resolved)
{
<span>Løst @Display.Time(resolved) · </span>
}
Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt)
@if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> }
</p>
<section class="panel">
<h2>Hvorfor er det et problem?</h2>
<p>@_finding.Explanation</p>
</section>
@if (_finding.Evidence.Count > 0)
{
<section class="panel">
<h2>Målinger</h2>
<table class="evidence">
@foreach (var e in _finding.Evidence)
{
<tr><th>@e.Name</th><td>@e.Value</td></tr>
}
</table>
</section>
}
<section class="panel recommendation">
<h2>Hvad skal jeg gøre?</h2>
<p>@_finding.Recommendation</p>
@if (!string.IsNullOrEmpty(_finding.FixScript))
{
<h3>Forslag til script</h3>
<p class="muted">Gennemgå og test scriptet, før det køres. SQLInsight Pro kører det aldrig selv.</p>
<div class="script">
<button type="button" class="copy" data-copy="fix-script">Kopiér</button>
<pre id="fix-script">@_finding.FixScript</pre>
</div>
}
</section>
@if (_finding.References.Count > 0)
{
<section class="panel">
<h2>Læs mere</h2>
<ul>
@foreach (var r in _finding.References)
{
<li><a href="@r.Url" rel="noopener noreferrer" target="_blank">@r.Title</a></li>
}
</ul>
</section>
}
@code {
[Parameter] public long Id { get; set; }
private StoredFinding? _finding;
protected override async Task OnInitializedAsync() => _finding = await Repository.GetFindingAsync(Id);
}

View file

@ -0,0 +1,77 @@
@page "/"
@inject InsightRepository Repository
@inject Display Display
<PageTitle>SQLInsight Pro</PageTitle>
<h1>Overblik</h1>
<p class="lead">Status for alle overvågede SQL Server-instanser.</p>
@if (_instances.Count == 0)
{
<div class="empty">
<p>Der er endnu ingen data. Tjek, at SQLInsight Agent kører og har instanser under <code>SQLInsight:Instances</code> i sin konfiguration.</p>
</div>
}
else
{
<div class="kpis">
<div class="kpi"><span class="kpi-icon"><Icon Name="servers" /></span><div><strong>@_instances.Count</strong><span>instanser</span></div></div>
<div class="kpi sev-critical"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_instances.Sum(i => i.Critical)</strong><span>kritiske fund</span></div></div>
<div class="kpi sev-high"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_instances.Sum(i => i.High)</strong><span>høje fund</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="unplug" /></span><div><strong>@_instances.Count(i => i.IsFailing)</strong><span>kan ikke nås</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="gauge" /></span><div><strong>@_instances.Sum(i => i.NotMeasurable)</strong><span>regler ikke målbare</span></div></div>
</div>
<section class="card">
<div class="card-head"><h2>Instanser</h2><span class="muted">sorteret efter alvor</span></div>
<table class="instances">
<thead>
<tr><th>Status</th><th>Instans</th><th>Version</th><th>Fund</th><th>Ikke målbar</th><th>Sidst set</th></tr>
</thead>
<tbody>
@foreach (var i in _instances.OrderByDescending(i => i.IsFailing).ThenByDescending(i => i.WorstSeverity ?? (Severity)(-1)).ThenBy(i => i.Name))
{
<tr>
<td>
@if (i.IsFailing)
{
<span class="badge sev-critical" title="@i.LastError">Fejler</span>
}
else
{
<SeverityBadge Severity="i.WorstSeverity" />
}
</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))<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel</div></td>
<td class="counts">
@if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> }
@if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> }
@if (i.Medium > 0) { <SeverityBadge Severity="Severity.Medium" Count="i.Medium" /> }
@if (i.Low > 0) { <SeverityBadge Severity="Severity.Low" Count="i.Low" /> }
</td>
<td>@i.NotMeasurable</td>
<td title="@Display.Time(i.LastSeenAt)">@Display.Ago(i.LastSeenAt)</td>
</tr>
}
</tbody>
</table>
</section>
<section class="card">
<div class="card-head"><h2>Kritiske og høje fund</h2><a href="/fund">Se alle fund</a></div>
<FindingList Findings="_urgent" ShowInstance="true" Empty="Ingen kritiske eller høje fund." />
</section>
}
@code {
private IReadOnlyList<InstanceOverview> _instances = [];
private IReadOnlyList<StoredFinding> _urgent = [];
protected override async Task OnInitializedAsync()
{
_instances = await Repository.GetInstancesAsync();
_urgent = (await Repository.GetActiveFindingsAsync()).Where(f => f.Severity >= Severity.High).ToList();
}
}

View file

@ -0,0 +1,235 @@
@page "/instanser/{Name}"
@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>@_instance.Name · SQLInsight Pro</PageTitle>
<p class="crumbs"><a href="/">Instanser</a> / @_instance.Name</p>
<h1>@_instance.Name</h1>
<p class="lead">
@_instance.ServerName · @Display.Release(_instance.ProductVersion) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition
· sidst set @Display.Ago(_instance.LastSeenAt)
</p>
@if (_instance.IsFailing)
{
<div class="alert">
<strong>Seneste forsøg på at nå instansen fejlede</strong> (@Display.Time(_instance.LastErrorAt)):
<pre>@_instance.LastError</pre>
</div>
}
@if (_instance.UsedLegacyFallback)
{
<div class="alert warn">Forbindelsen bruger legacy ODBC-laget, fordi instansen er for gammel til SqlClient. Krypteringen kan være svagere.</div>
}
<section class="card">
<div class="card-head"><h2>Aktive fund</h2><span class="muted">@_findings.Count fund</span></div>
<FindingList Findings="_findings" Empty="Ingen aktive fund." />
</section>
<h2 class="section">Belastning, seneste 24 timer</h2>
<div class="grid2">
<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="_batches" Label="Batch requests pr. sekund" Decimals="1" /></div>
<div class="panel"><Sparkline Points="_ple" Label="Page life expectancy (s)" /></div>
</div>
<section class="card section">
<div class="card-head"><h2>Største ventetider, seneste 24 timer</h2><span class="muted">uden harmløse baggrundsventetider</span></div>
@if (_waits.Count == 0)
{
<p class="muted">Endnu ikke nok målinger (ventetider kræver to målinger).</p>
}
else
{
var total = Math.Max(1, _waits.Sum(w => w.WaitTimeMs));
<table>
<thead><tr><th>Wait type</th><th>Kategori</th><th class="num">Ventetid</th><th class="num">Andel</th><th class="num">Gns. pr. wait</th><th class="num">Signal</th></tr></thead>
<tbody>
@foreach (var w in _waits)
{
<tr>
<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"><meter min="0" max="1" value="@(((double)w.WaitTimeMs / total).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture))"></meter> @Display.Number(100.0 * w.WaitTimeMs / total) %</td>
<td class="num">@Display.Number(w.WaitingTasks == 0 ? 0 : (double)w.WaitTimeMs / w.WaitingTasks, 1) ms</td>
<td class="num">@Display.Number(w.WaitTimeMs == 0 ? 0 : 100.0 * w.SignalWaitTimeMs / w.WaitTimeMs) %</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>I/O-latens pr. fil, seneste 24 timer</h2></div>
@if (_files.Count == 0)
{
<p class="muted">Endnu ikke nok målinger.</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 _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 @LatencyClass(f.AverageReadMs, f.FileType == "LOG" ? 20 : 20)">@Ms(f.AverageReadMs)</td>
<td class="num">@Display.Number(f.Writes)</td>
<td class="num @LatencyClass(f.AverageWriteMs, f.FileType == "LOG" ? 5 : 20)">@Ms(f.AverageWriteMs)</td>
</tr>
}
</tbody>
</table>
}
</section>
<div class="grid2">
<section class="card">
<div class="card-head"><h2>Blocking, seneste 24 timer</h2></div>
@if (_blocking.Count == 0)
{
<p class="muted">Ingen blocking over 5 sekunder.</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 _blocking.Take(20))
{
var head = b.Sessions.FirstOrDefault();
<tr>
<td>@Display.Time(b.SampledAt)</td>
<td>@b.HeadSessionId <span class="muted">@head?.Status @head?.ProgramName</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, seneste 7 dage</h2></div>
@if (_deadlocks.Count == 0)
{
<p class="muted">Ingen deadlocks registreret.</p>
}
else
{
<table>
<thead><tr><th>Tidspunkt</th><th>Offer</th><th>Graf</th></tr></thead>
<tbody>
@foreach (var d in _deadlocks.Take(20))
{
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>Ikke målbart på denne instans</h2></div>
@if (_notMeasurable.Count == 0)
{
<p class="muted">Alle regler kunne evalueres.</p>
}
else
{
<p class="muted">Disse regler kunne ikke vurderes. Det er ikke det samme som "OK".</p>
<table>
<thead><tr><th>Regel</th><th></th><th>Årsag</th></tr></thead>
<tbody>
@foreach (var r in _notMeasurable)
{
<tr><td class="mono">@r.RuleId</td><td>@r.RuleTitle</td><td>@r.Reason</td></tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>Løst de seneste 30 dage</h2></div>
@if (_resolved.Count == 0)
{
<p class="muted">Ingen.</p>
}
else
{
<table>
<thead><tr><th>Fund</th><th>Aktivt fra</th><th>Løst</th></tr></thead>
<tbody>
@foreach (var f in _resolved)
{
<tr><td><a href="/fund/@f.FindingId">@f.Title</a></td><td>@Display.Time(f.FirstSeenAt)</td><td>@Display.Time(f.ResolvedAt)</td></tr>
}
</tbody>
</table>
}
</section>
@code {
[Parameter] public string Name { get; set; } = string.Empty;
private InstanceOverview? _instance;
private IReadOnlyList<StoredFinding> _findings = [];
private IReadOnlyList<StoredFinding> _resolved = [];
private IReadOnlyList<StoredRuleState> _notMeasurable = [];
private IReadOnlyList<MetricPoint> _cpuSql = [], _cpuOther = [], _batches = [], _ple = [];
private IReadOnlyList<WaitTotal> _waits = [];
private IReadOnlyList<FileLatency> _files = [];
private IReadOnlyList<StoredBlocking> _blocking = [];
private IReadOnlyList<StoredDeadlock> _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;
}

View file

@ -0,0 +1,5 @@
@page "/ikke-fundet"
<PageTitle>Ikke fundet · SQLInsight Pro</PageTitle>
<h1>Siden findes ikke</h1>
<p><a href="/">Tilbage til oversigten</a></p>

View file

@ -0,0 +1,5 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
</Router>

View file

@ -0,0 +1,32 @@
@inject Display Display
@if (Findings.Count == 0)
{
<p class="empty">@Empty</p>
}
else
{
<table class="findings">
<thead>
<tr><th>Alvor</th>@if (ShowInstance) { <th>Instans</th> }<th>Fund</th><th>Regel</th><th>Set første gang</th></tr>
</thead>
<tbody>
@foreach (var f in Findings)
{
<tr>
<td><SeverityBadge Severity="f.Severity" /></td>
@if (ShowInstance) { <td><a href="/instanser/@Uri.EscapeDataString(f.InstanceName)">@f.InstanceName</a></td> }
<td><a href="/fund/@f.FindingId">@f.Title</a></td>
<td class="mono">@f.RuleId</td>
<td title="@Display.Time(f.FirstSeenAt)">@Display.Ago(f.FirstSeenAt)</td>
</tr>
}
</tbody>
</table>
}
@code {
[Parameter, EditorRequired] public IReadOnlyList<StoredFinding> Findings { get; set; } = [];
[Parameter] public bool ShowInstance { get; set; }
[Parameter] public string Empty { get; set; } = "Ingen fund.";
}

View file

@ -0,0 +1,40 @@
@* Simple stroke icons (24×24, currentColor). *@
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@switch (Name)
{
case "servers":
<rect x="3" y="4" width="18" height="7" rx="2" /><rect x="3" y="13" width="18" height="7" rx="2" /><path d="M7 7.5h.01M7 16.5h.01" />
break;
case "alert":
<path d="M12 3 2 20h20L12 3Z" /><path d="M12 10v4M12 17h.01" />
break;
case "list":
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
break;
case "book":
<path d="M4 5a2 2 0 0 1 2-2h14v16H6a2 2 0 0 0-2 2V5Z" /><path d="M4 19a2 2 0 0 1 2-2h14" />
break;
case "users":
<circle cx="9" cy="8" r="3.5" /><path d="M2.5 20a6.5 6.5 0 0 1 13 0" /><path d="M16 4.5a3.5 3.5 0 0 1 0 7M18 14a6.5 6.5 0 0 1 3.5 6" />
break;
case "support":
<circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><path d="m5.6 5.6 3.6 3.6M14.8 14.8l3.6 3.6M18.4 5.6l-3.6 3.6M9.2 14.8l-3.6 3.6" />
break;
case "moon":
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
break;
case "unplug":
<path d="m2 22 5-5M9 7 7 9a5 5 0 0 0 7 7l2-2M15 17l2 2M7 9 5 7M13 3l-2 2M21 11l-2 2M15 9l6-6" />
break;
case "gauge":
<path d="M12 14 16 10" /><path d="M3.5 18a9.5 9.5 0 1 1 17 0" />
break;
default:
<circle cx="12" cy="12" r="9" />
break;
}
</svg>
@code {
[Parameter, EditorRequired] public string Name { get; set; } = string.Empty;
}

View file

@ -0,0 +1,6 @@
<span class="badge @Display.SeverityClass(Severity)">@(Severity is { } s ? Display.SeverityName(s) : "OK")@(Count is { } c ? $" {c}" : "")</span>
@code {
[Parameter] public Severity? Severity { get; set; }
[Parameter] public int? Count { get; set; }
}

View file

@ -0,0 +1,50 @@
@* A small inline SVG line chart; no JavaScript. *@
@if (Points.Count < 2)
{
<p class="muted">@Label: @Empty</p>
}
else
{
<figure class="chart">
<svg viewBox="0 0 @Width @Height" preserveAspectRatio="none" role="img" aria-label="@Label">
@foreach (var line in GridLines)
{
<line x1="0" x2="@Width" y1="@Y(line)" y2="@Y(line)" class="grid" />
}
<polyline points="@Path" class="line" />
</svg>
<figcaption>
<span>@Label</span>
<span>gns. @Display.Number(Points.Average(p => p.Value), Decimals)@Unit · maks. @Display.Number(Points.Max(p => p.Value), Decimals)@Unit</span>
</figcaption>
</figure>
}
@code {
private const int Width = 600;
private const int Height = 90;
[Parameter, EditorRequired] public IReadOnlyList<MetricPoint> 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<double> 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)));
}
}
}

View file

@ -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

View file

@ -0,0 +1,60 @@
using System.Globalization;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
namespace SQLInsight.Web;
/// <summary>
/// Formatting for the UI: times in the configured time zone, Danish number format, severity names.
/// </summary>
public sealed class Display(IOptions<WebOptions> 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";
}

View file

@ -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); 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<WebOptions>(builder.Configuration.GetSection(WebOptions.SectionName));
var web = builder.Configuration.GetSection(WebOptions.SectionName).Get<WebOptions>() ?? 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<Display>();
// Danish text (æ, ø, å) as characters rather than &#xE6; 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(); 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<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.
app.Run(); app.Run();
/// <summary>Entry point, visible to integration tests.</summary>
public partial class Program;

View file

@ -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"
}
}
}
}

View file

@ -4,5 +4,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" /> <ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
<ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Negotiate" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,15 @@
namespace SQLInsight.Web;
public sealed class WebOptions
{
public const string SectionName = "SQLInsight:Web";
/// <summary>Windows (Negotiate/Kerberos) or None. None is for development only.</summary>
public string Authentication { get; set; } = "Windows";
/// <summary>Windows groups allowed to use the UI (e.g. DOMAIN\SQL-Admins). Empty: any authenticated user.</summary>
public IList<string> AllowedGroups { get; } = [];
/// <summary>Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.</summary>
public string? TimeZone { get; set; }
}

View file

@ -4,5 +4,10 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"SQLInsight": {
"Web": {
"Authentication": "None"
}
} }
} }

View file

@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning" "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"
}
}
} }

View file

@ -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; }
}

View file

@ -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 */ }
}
});

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0e7490"/><stop offset="1" stop-color="#155e75"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><text x="16" y="21" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" fill="#fff">SI</text></svg>

After

Width:  |  Height:  |  Size: 415 B

View file

@ -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.

View file

@ -0,0 +1,3 @@
try {
if (localStorage.getItem('sqlinsight-theme') === 'dark') document.documentElement.dataset.theme = 'dark';
} catch { /* storage blocked: stay light */ }

View file

@ -81,6 +81,24 @@ public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime
Assert.NotEmpty(await _repository.GetTopWaitsAsync(instance.InstanceId, start.AddHours(-1))); 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] [Fact]
public async Task Unreachable_instance_is_recorded_as_failing() public async Task Unreachable_instance_is_recorded_as_failing()
{ {

View file

@ -119,6 +119,8 @@ public class CollectorTests(SqlServerFixture sql)
// The ring buffer gets its first record about a minute after startup. // 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.All(cpu.Samples, s => Assert.InRange(s.SqlServerPercent + s.IdlePercent, 0, 100));
Assert.Equal(cpu.Samples.OrderBy(s => s.Time), cpu.Samples); 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] [Fact]

View file

@ -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)] }; 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);
await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks); // duplicates are ignored
await _repository.SaveCollectedAsync(id, "blocking", blocking); await _repository.SaveCollectedAsync(id, "blocking", blocking);
await _repository.SaveCollectedAsync(id, "configuration", config); await _repository.SaveCollectedAsync(id, "configuration", config);
var deadlock = Assert.Single(await _repository.GetDeadlocksAsync(id, at.AddHours(-1))); 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.True(Assert.Single(deadlock.Processes).IsVictim);
Assert.Equal("<deadlock/>", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml); Assert.Equal("<deadlock/>", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml);

View file

@ -4,6 +4,7 @@
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Testcontainers.MsSql" /> <PackageReference Include="Testcontainers.MsSql" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
@ -11,6 +12,7 @@
<ProjectReference Include="..\..\src\SQLInsight.Collectors\SQLInsight.Collectors.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Repository\SQLInsight.Repository.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Repository\SQLInsight.Repository.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Agent\SQLInsight.Agent.csproj" /> <ProjectReference Include="..\..\src\SQLInsight.Agent\SQLInsight.Agent.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Web\SQLInsight.Web.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" /> <None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />

View file

@ -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<Program> _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 <script>alert(1)</script>",
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 = "<deadlock><victim-list /></deadlock>", 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<Program>().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("<script>alert(1)</script>", html, StringComparison.Ordinal);
Assert.Contains("&lt;script&gt;", 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("<deadlock>", 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<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Production");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var ex = Assert.ThrowsAny<Exception>(() => production.CreateClient());
Assert.Contains("kun tilladt i Development", ex.ToString(), StringComparison.Ordinal);
}
}

View file

@ -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, CapturedAt = Now,
HostPlatform = platform,
Samples = Enumerable.Range(0, minutes).Select(i => new CpuSample(Now.AddMinutes(i - minutes), sql, idle)).ToList(), 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); 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] [Fact]
public void Cpu_needs_enough_history() public void Cpu_needs_enough_history()
{ {