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
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:
parent
005a4f9663
commit
5bb1c37fef
42 changed files with 1453 additions and 39 deletions
|
|
@ -10,6 +10,8 @@
|
|||
<PackageVersion Include="Microsoft.Extensions.Hosting" 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.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
|
|
|
|||
108
docs/installation.md
Normal file
108
docs/installation.md
Normal 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.
|
||||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
private static partial void LogConnectFailed(ILogger logger, Exception exception, string instance);
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ public sealed class CpuCollector : Collector<CpuUtilization>
|
|||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ public sealed record CpuUtilization
|
|||
{
|
||||
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>
|
||||
public required IReadOnlyList<CpuSample> Samples { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ namespace SQLInsight.Repository;
|
|||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<int> 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<int> 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) }));
|
||||
|
||||
/// <summary>Blocking shorter than this is normal and not stored.</summary>
|
||||
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("""
|
||||
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
|
||||
{
|
||||
/// <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 const string CpuSqlServer = "cpu.sqlserver";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SQLInsight.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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<CpuUtilization>();
|
||||
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 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)
|
||||
|
|
|
|||
18
src/SQLInsight.Web/Components/App.razor
Normal file
18
src/SQLInsight.Web/Components/App.razor
Normal 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>
|
||||
40
src/SQLInsight.Web/Components/Layout/MainLayout.razor
Normal file
40
src/SQLInsight.Web/Components/Layout/MainLayout.razor
Normal 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>
|
||||
16
src/SQLInsight.Web/Components/Pages/AllFindings.razor
Normal file
16
src/SQLInsight.Web/Components/Pages/AllFindings.razor
Normal 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();
|
||||
}
|
||||
5
src/SQLInsight.Web/Components/Pages/Error.razor
Normal file
5
src/SQLInsight.Web/Components/Pages/Error.razor
Normal 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>
|
||||
76
src/SQLInsight.Web/Components/Pages/FindingDetail.razor
Normal file
76
src/SQLInsight.Web/Components/Pages/FindingDetail.razor
Normal 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);
|
||||
}
|
||||
77
src/SQLInsight.Web/Components/Pages/Home.razor
Normal file
77
src/SQLInsight.Web/Components/Pages/Home.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
235
src/SQLInsight.Web/Components/Pages/Instance.razor
Normal file
235
src/SQLInsight.Web/Components/Pages/Instance.razor
Normal 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;
|
||||
}
|
||||
5
src/SQLInsight.Web/Components/Pages/NotFound.razor
Normal file
5
src/SQLInsight.Web/Components/Pages/NotFound.razor
Normal 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>
|
||||
5
src/SQLInsight.Web/Components/Routes.razor
Normal file
5
src/SQLInsight.Web/Components/Routes.razor
Normal 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>
|
||||
32
src/SQLInsight.Web/Components/Shared/FindingList.razor
Normal file
32
src/SQLInsight.Web/Components/Shared/FindingList.razor
Normal 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.";
|
||||
}
|
||||
40
src/SQLInsight.Web/Components/Shared/Icon.razor
Normal file
40
src/SQLInsight.Web/Components/Shared/Icon.razor
Normal 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;
|
||||
}
|
||||
6
src/SQLInsight.Web/Components/Shared/SeverityBadge.razor
Normal file
6
src/SQLInsight.Web/Components/Shared/SeverityBadge.razor
Normal 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; }
|
||||
}
|
||||
50
src/SQLInsight.Web/Components/Shared/Sparkline.razor
Normal file
50
src/SQLInsight.Web/Components/Shared/Sparkline.razor
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/SQLInsight.Web/Components/_Imports.razor
Normal file
12
src/SQLInsight.Web/Components/_Imports.razor
Normal 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
|
||||
60
src/SQLInsight.Web/Display.cs
Normal file
60
src/SQLInsight.Web/Display.cs
Normal 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";
|
||||
}
|
||||
|
|
@ -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<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 æ 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<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();
|
||||
|
||||
/// <summary>Entry point, visible to integration tests.</summary>
|
||||
public partial class Program;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,5 +4,11 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<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>
|
||||
</Project>
|
||||
|
|
|
|||
15
src/SQLInsight.Web/WebOptions.cs
Normal file
15
src/SQLInsight.Web/WebOptions.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -4,5 +4,10 @@
|
|||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"SQLInsight": {
|
||||
"Web": {
|
||||
"Authentication": "None"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
156
src/SQLInsight.Web/wwwroot/app.css
Normal file
156
src/SQLInsight.Web/wwwroot/app.css
Normal 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; }
|
||||
}
|
||||
22
src/SQLInsight.Web/wwwroot/app.js
Normal file
22
src/SQLInsight.Web/wwwroot/app.js
Normal 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 */ }
|
||||
}
|
||||
});
|
||||
1
src/SQLInsight.Web/wwwroot/favicon.svg
Normal file
1
src/SQLInsight.Web/wwwroot/favicon.svg
Normal 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 |
93
src/SQLInsight.Web/wwwroot/fonts/Inter-OFL.txt
Normal file
93
src/SQLInsight.Web/wwwroot/fonts/Inter-OFL.txt
Normal 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.
|
||||
Binary file not shown.
BIN
src/SQLInsight.Web/wwwroot/fonts/inter-latin-wght-normal.woff2
Normal file
BIN
src/SQLInsight.Web/wwwroot/fonts/inter-latin-wght-normal.woff2
Normal file
Binary file not shown.
3
src/SQLInsight.Web/wwwroot/theme.js
Normal file
3
src/SQLInsight.Web/wwwroot/theme.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
try {
|
||||
if (localStorage.getItem('sqlinsight-theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
||||
} catch { /* storage blocked: stay light */ }
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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("<deadlock/>", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Testcontainers.MsSql" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
<ProjectReference Include="..\..\src\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Repository\SQLInsight.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Agent\SQLInsight.Agent.csproj" />
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Web\SQLInsight.Web.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
|
|
|
|||
160
tests/SQLInsight.IntegrationTests/WebTests.cs
Normal file
160
tests/SQLInsight.IntegrationTests/WebTests.cs
Normal 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("<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("<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue