diff --git a/Directory.Packages.props b/Directory.Packages.props index b99d1f5..30d90f3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,8 @@ + + diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..02b4f13 --- /dev/null +++ b/docs/installation.md @@ -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. diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs index dbf97cb..8afb51e 100644 --- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs +++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs @@ -102,7 +102,11 @@ public sealed partial class InstanceMonitor( 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); _previous[collector.Name] = result; _data.Set(collector.DataType, result); @@ -116,6 +120,21 @@ public sealed partial class InstanceMonitor( } } + /// + /// 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). + /// + private async Task 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")] private static partial void LogConnectFailed(ILogger logger, Exception exception, string instance); diff --git a/src/SQLInsight.Collectors/Performance/CpuCollector.cs b/src/SQLInsight.Collectors/Performance/CpuCollector.cs index b931703..6fe402f 100644 --- a/src/SQLInsight.Collectors/Performance/CpuCollector.cs +++ b/src/SQLInsight.Collectors/Performance/CpuCollector.cs @@ -37,7 +37,10 @@ public sealed class CpuCollector : Collector context.CancellationToken).ConfigureAwait(false); 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 }; } /// diff --git a/src/SQLInsight.Core/Data/CpuUtilization.cs b/src/SQLInsight.Core/Data/CpuUtilization.cs index 987bc52..9343d40 100644 --- a/src/SQLInsight.Core/Data/CpuUtilization.cs +++ b/src/SQLInsight.Core/Data/CpuUtilization.cs @@ -8,6 +8,14 @@ public sealed record CpuUtilization { public required DateTimeOffset CapturedAt { get; init; } + /// + /// 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. + /// + public string? HostPlatform { get; init; } + + public bool OtherProcessesMeasurable => !string.Equals(HostPlatform, "Linux", StringComparison.OrdinalIgnoreCase); + /// One sample per minute, oldest first. public required IReadOnlyList Samples { get; init; } } diff --git a/src/SQLInsight.Repository/InsightRepository.cs b/src/SQLInsight.Repository/InsightRepository.cs index 9b1e623..2dac9f4 100644 --- a/src/SQLInsight.Repository/InsightRepository.cs +++ b/src/SQLInsight.Repository/InsightRepository.cs @@ -14,6 +14,15 @@ namespace SQLInsight.Repository; /// 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. 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); } + /// UTC truncated to milliseconds, matching datetime2(3) (deadlock times must survive a round trip exactly). + internal static DateTime UtcMilliseconds(DateTimeOffset value) + { + var utc = value.UtcDateTime; + return new DateTime(utc.Ticks - (utc.Ticks % TimeSpan.TicksPerMillisecond), DateTimeKind.Utc); + } + /// Registers a successful contact with an instance and returns its id. public async Task UpsertInstanceAsync( 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 SaveCpuAsync(SqlConnection connection, int instanceId, CpuUtilization cpu) => - InsertMetricsAsync(connection, instanceId, cpu.Samples.SelectMany(s => new[] - { - (Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent), - (Metrics.CpuOther, s.Time, (double)s.OtherProcessesPercent), - })); + InsertMetricsAsync(connection, instanceId, cpu.Samples.SelectMany(s => + cpu.OtherProcessesMeasurable + ? new[] { (Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent), (Metrics.CpuOther, s.Time, (double)s.OtherProcessesPercent) } + : new[] { (Metrics.CpuSqlServer, s.Time, (double)s.SqlServerPercent) })); /// Blocking shorter than this is normal and not stored. public const long BlockingThresholdMs = 5000; @@ -298,12 +313,13 @@ public sealed partial class InsightRepository(string connectionString) foreach (var deadlock in snapshot.NewDeadlocks) { await connection.ExecuteAsync(""" - INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) - VALUES (@instanceId, @at, @processes, @graph); + IF NOT EXISTS (SELECT 1 FROM insight.Deadlocks WHERE InstanceId = @instanceId AND OccurredAt = @at AND GraphXml = @graph) + INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) + VALUES (@instanceId, @at, @processes, @graph); """, new { instanceId, - at = Utc(deadlock.Time), + at = UtcMilliseconds(deadlock.Time), processes = JsonSerializer.Serialize(deadlock.Processes, Json), graph = deadlock.GraphXml, }).ConfigureAwait(false); @@ -311,6 +327,21 @@ public sealed partial class InsightRepository(string connectionString) } } +public sealed partial class InsightRepository +{ + /// + /// The newest stored deadlock for an instance, so a restarted agent continues after it instead of reporting + /// deadlocks it has already stored. + /// + public async Task GetLatestDeadlockTimeAsync(int instanceId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var latest = await connection.ExecuteScalarAsync( + "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 const string CpuSqlServer = "cpu.sqlserver"; diff --git a/src/SQLInsight.Repository/SQLInsight.Repository.csproj b/src/SQLInsight.Repository/SQLInsight.Repository.csproj index 782372b..0d679c9 100644 --- a/src/SQLInsight.Repository/SQLInsight.Repository.csproj +++ b/src/SQLInsight.Repository/SQLInsight.Repository.csproj @@ -10,6 +10,9 @@ + + + diff --git a/src/SQLInsight.Rules/Performance/ResourceRules.cs b/src/SQLInsight.Rules/Performance/ResourceRules.cs index 7532f7f..f3407ef 100644 --- a/src/SQLInsight.Rules/Performance/ResourceRules.cs +++ b/src/SQLInsight.Rules/Performance/ResourceRules.cs @@ -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)."); } + var cpu = context.Data.Get(); 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(); var evidence = new[] { new Evidence("Periode", $"{samples.Count} min"), 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) diff --git a/src/SQLInsight.Web/Components/App.razor b/src/SQLInsight.Web/Components/App.razor new file mode 100644 index 0000000..d3ef523 --- /dev/null +++ b/src/SQLInsight.Web/Components/App.razor @@ -0,0 +1,18 @@ + + + + + + + + + + @* Applies a saved theme before first paint. Light is the default; dark only when chosen. *@ + + + + + + + + diff --git a/src/SQLInsight.Web/Components/Layout/MainLayout.razor b/src/SQLInsight.Web/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..d59d2c3 --- /dev/null +++ b/src/SQLInsight.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,40 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ Gratis og open source · kører on-prem + +
+
+ @Body +
+ +
+
diff --git a/src/SQLInsight.Web/Components/Pages/AllFindings.razor b/src/SQLInsight.Web/Components/Pages/AllFindings.razor new file mode 100644 index 0000000..77b2acb --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/AllFindings.razor @@ -0,0 +1,16 @@ +@page "/fund" +@inject InsightRepository Repository + +Alle fund · SQLInsight Pro + +

Alle aktive fund

+

Sorteret efter alvor på tværs af alle instanser.

+
+ +
+ +@code { + private IReadOnlyList _findings = []; + + protected override async Task OnInitializedAsync() => _findings = await Repository.GetActiveFindingsAsync(); +} diff --git a/src/SQLInsight.Web/Components/Pages/Error.razor b/src/SQLInsight.Web/Components/Pages/Error.razor new file mode 100644 index 0000000..665f763 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/Error.razor @@ -0,0 +1,5 @@ +@page "/fejl" + +Fejl · SQLInsight Pro +

Der opstod en fejl

+

Fejlen er logget. Tjek, at repository-databasen kan nås.

diff --git a/src/SQLInsight.Web/Components/Pages/FindingDetail.razor b/src/SQLInsight.Web/Components/Pages/FindingDetail.razor new file mode 100644 index 0000000..13f1de3 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/FindingDetail.razor @@ -0,0 +1,76 @@ +@page "/fund/{Id:long}" +@inject InsightRepository Repository +@inject Display Display + +@if (_finding is null) +{ + Ukendt fund · SQLInsight Pro +

Ukendt fund

+ return; +} + +@_finding.Title · SQLInsight Pro + +

Instanser / @_finding.InstanceName / @_finding.RuleId

+

@_finding.Title

+

+ @if (_finding.ResolvedAt is { } resolved) + { + Løst @Display.Time(resolved) · + } + Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt) + @if (!string.IsNullOrEmpty(_finding.Subject)) { · @_finding.Subject } +

+ +
+

Hvorfor er det et problem?

+

@_finding.Explanation

+
+ +@if (_finding.Evidence.Count > 0) +{ +
+

Målinger

+ + @foreach (var e in _finding.Evidence) + { + + } +
@e.Name@e.Value
+
+} + +
+

Hvad skal jeg gøre?

+

@_finding.Recommendation

+ @if (!string.IsNullOrEmpty(_finding.FixScript)) + { +

Forslag til script

+

Gennemgå og test scriptet, før det køres. SQLInsight Pro kører det aldrig selv.

+
+ +
@_finding.FixScript
+
+ } +
+ +@if (_finding.References.Count > 0) +{ +
+

Læs mere

+
    + @foreach (var r in _finding.References) + { +
  • @r.Title
  • + } +
+
+} + +@code { + [Parameter] public long Id { get; set; } + + private StoredFinding? _finding; + + protected override async Task OnInitializedAsync() => _finding = await Repository.GetFindingAsync(Id); +} diff --git a/src/SQLInsight.Web/Components/Pages/Home.razor b/src/SQLInsight.Web/Components/Pages/Home.razor new file mode 100644 index 0000000..a2d464d --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/Home.razor @@ -0,0 +1,77 @@ +@page "/" +@inject InsightRepository Repository +@inject Display Display + +SQLInsight Pro + +

Overblik

+

Status for alle overvågede SQL Server-instanser.

+ +@if (_instances.Count == 0) +{ +
+

Der er endnu ingen data. Tjek, at SQLInsight Agent kører og har instanser under SQLInsight:Instances i sin konfiguration.

+
+} +else +{ +
+
@_instances.Countinstanser
+
@_instances.Sum(i => i.Critical)kritiske fund
+
@_instances.Sum(i => i.High)høje fund
+
@_instances.Count(i => i.IsFailing)kan ikke nås
+
@_instances.Sum(i => i.NotMeasurable)regler ikke målbare
+
+ +
+

Instanser

sorteret efter alvor
+ + + + + + @foreach (var i in _instances.OrderByDescending(i => i.IsFailing).ThenByDescending(i => i.WorstSeverity ?? (Severity)(-1)).ThenBy(i => i.Name)) + { + + + + + + + + + } + +
StatusInstansVersionFundIkke målbarSidst set
+ @if (i.IsFailing) + { + Fejler + } + else + { + + } + @i.Name
@i.ServerName
@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion))
@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel
+ @if (i.Critical > 0) { } + @if (i.High > 0) { } + @if (i.Medium > 0) { } + @if (i.Low > 0) { } + @i.NotMeasurable@Display.Ago(i.LastSeenAt)
+
+ +
+

Kritiske og høje fund

Se alle fund
+ +
+} + +@code { + private IReadOnlyList _instances = []; + private IReadOnlyList _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)); + + + + @foreach (var w in _waits) + { + + + + + + + + + } + +
Wait typeKategoriVentetidAndelGns. pr. waitSignal
@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 +{ + + + + @foreach (var f in _files.Take(25)) + { + + + + + + + + + + } + +
DatabaseFilTypeLæsningerGns. læsSkrivningerGns. skriv
@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 + { + + + + @foreach (var b in _blocking.Take(20)) + { + var head = b.Sessions.FirstOrDefault(); + + + + + + + } + +
TidspunktHead blockerBlokeredeLængste
@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 + { + + + + @foreach (var d in _deadlocks.Take(20)) + { + var victim = d.Processes.FirstOrDefault(p => p.IsVictim); + + + + + + } + +
TidspunktOfferGraf
@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".

+ + + + @foreach (var r in _notMeasurable) + { + + } + +
RegelÅrsag
@r.RuleId@r.RuleTitle@r.Reason
+} +
+ +
+

Løst de seneste 30 dage

+@if (_resolved.Count == 0) +{ +

Ingen.

+} +else +{ + + + + @foreach (var f in _resolved) + { + + } + +
FundAktivt fraLøst
@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 +{ + + + @if (ShowInstance) { } + + + @foreach (var f in Findings) + { + + + @if (ShowInstance) { } + + + + + } + +
AlvorInstansFundRegelSet første gang
@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). *@ + + +@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() {