diff --git a/db/permissions/grant-windows-server.ps1 b/db/permissions/grant-windows-server.ps1
new file mode 100644
index 0000000..7ad104a
--- /dev/null
+++ b/db/permissions/grant-windows-server.ps1
@@ -0,0 +1,148 @@
+<#
+ SQLInsight Pro: least-privilege rettigheder til agentens konto på en Windows-server
+ =====================================================================================
+
+ Giver SQLInsight-agentens konto præcis den læseadgang, den skal bruge for at overvåge denne server:
+ CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver. Aldrig lokal administrator.
+ Scriptet kan køres igen uden skade. Det ændrer kun adgangsrettigheder: det starter, stopper eller
+ ændrer ingen tjenester, opgaver eller indstillinger, og det læser ingen data.
+
+ Kør som administrator på den server, der skal overvåges (Windows PowerShell 2.0 eller nyere):
+ .\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$'
+ .\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -EnableFirewall -AgentAddress 10.0.0.15
+ Fjern rettighederne igen:
+ .\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove
+
+ Agentens konto: kører agenten som NT SERVICE\SQLInsight Agent, LocalSystem eller NETWORK SERVICE, kommer den
+ til andre servere som computerkontoen DOMÆNE\COMPUTERNAVN$. En gMSA eller domænekonto er den samme overalt.
+
+ Hvad der gives, og hvorfor:
+ Performance Monitor Users ydelsestællere: CPU, hukommelse, paging
+ Event Log Readers System- og Application-loggen (aldrig Security-loggen)
+ Distributed COM Users WMI/CIM over DCOM (RPC)
+ Remote Management Users WMI/CIM over WinRM (WS-Man), Windows Server 2012 og nyere
+ WMI: Enable Account + Remote Enable på root\cimv2 og root\Microsoft\Windows\TaskScheduler (ikke arvet)
+ Service Control Manager: læse og liste tjenester (CC LC RP RC). Den oprindelige SDDL gemmes første gang i
+ %ProgramData%\SQLInsight\scmanager-sddl-backup.txt
+
+ Hvad der IKKE gives: lokal administrator, ret til at starte/stoppe tjenester, skrive i WMI, læse filer
+ eller Security-loggen.
+
+ Firewall (kun med -EnableFirewall, Windows Server 2012 og nyere): slår Windows' indbyggede regler til for
+ WMI (DCOM), WinRM (TCP 5985) og Remote Event Log Management, eventuelt kun fra agentens adresse.
+#>
+param(
+ [string] $Account = 'DOMÆNE\AGENTSERVER$', # <== agentens konto
+ [switch] $Remove,
+ [switch] $EnableFirewall,
+ [string] $AgentAddress
+)
+
+$ErrorActionPreference = 'Stop'
+$sid = (New-Object System.Security.Principal.NTAccount($Account)).Translate([System.Security.Principal.SecurityIdentifier]).Value
+if ($Remove) { $verb = 'Fjerner' } else { $verb = 'Giver' }
+Write-Host "$verb rettigheder til $Account ($sid) på $env:COMPUTERNAME"
+
+# 1. Lokale grupper. Fundet via deres SID, så det virker på dansk, engelsk og andre sprog.
+$groups = @(
+ @('S-1-5-32-558', 'ydelsestællere (CPU, hukommelse, paging)'),
+ @('S-1-5-32-573', 'System- og Application-loggen'),
+ @('S-1-5-32-562', 'WMI over DCOM'),
+ @('S-1-5-32-580', 'WMI over WinRM (WS-Man)')
+)
+foreach ($g in $groups) {
+ try {
+ $groupName = (New-Object System.Security.Principal.SecurityIdentifier($g[0])).Translate([System.Security.Principal.NTAccount]).Value.Split('\')[1]
+ } catch {
+ Write-Host " - gruppen $($g[0]) ($($g[1])) findes ikke på denne Windows-version; springes over"
+ continue
+ }
+ $group = [ADSI]("WinNT://$env:COMPUTERNAME/$groupName,group")
+ $members = @($group.Invoke('Members') | ForEach-Object {
+ $bytes = $_.GetType().InvokeMember('objectSid', 'GetProperty', $null, $_, $null)
+ (New-Object System.Security.Principal.SecurityIdentifier($bytes, 0)).Value
+ })
+ $isMember = $members -contains $sid
+ $path = 'WinNT://' + $Account.Replace('\', '/')
+ if ($Remove -and $isMember) {
+ $group.Remove($path)
+ Write-Host " - fjernet fra $groupName"
+ } elseif (-not $Remove -and -not $isMember) {
+ $group.Add($path)
+ Write-Host " + medlem af $groupName ($($g[1]))"
+ } else {
+ Write-Host " = ${groupName}: uændret"
+ }
+}
+
+# 2. WMI-navnerum: Enable Account (0x1) + Remote Enable (0x20), kun på selve navnerummet.
+function Set-NamespaceAccess([string] $Namespace) {
+ $security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity
+ $descriptor = $security.PsBase.InvokeMethod('GetSecurityDescriptor', $null).Descriptor
+ $dacl = @($descriptor.DACL | Where-Object { $_.Trustee.SidString -ne $sid })
+ if (-not $Remove) {
+ $trustee = ([WMIClass]'Win32_Trustee').CreateInstance()
+ $trustee.SidString = $sid
+ $ace = ([WMIClass]'Win32_ACE').CreateInstance()
+ $ace.AccessMask = 0x21
+ $ace.AceFlags = 0
+ $ace.AceType = 0
+ $ace.Trustee = $trustee
+ $dacl += $ace.PsObject.ImmediateBaseObject
+ }
+ $descriptor.DACL = $dacl
+ $result = $security.PsBase.InvokeMethod('SetSecurityDescriptor', @($descriptor))
+ if ($result.ReturnValue -ne 0) { throw "SetSecurityDescriptor på $Namespace fejlede ($($result.ReturnValue))" }
+ if ($Remove) { Write-Host " - WMI $Namespace" } else { Write-Host " + WMI $Namespace (Enable Account, Remote Enable)" }
+}
+Set-NamespaceAccess 'root\cimv2'
+if (Get-WmiObject -Namespace 'root\Microsoft\Windows' -Class __Namespace -Filter "Name='TaskScheduler'" -ErrorAction SilentlyContinue) {
+ Set-NamespaceAccess 'root\Microsoft\Windows\TaskScheduler'
+} else {
+ Write-Host ' - root\Microsoft\Windows\TaskScheduler findes ikke (før Windows Server 2012): planlagte opgaver kan ikke måles'
+}
+
+# 3. Service Control Manager: læse og liste tjenester. Over netværket må ellers kun administratorer liste tjenester.
+$scmAce = "(A;;CCLCRPRC;;;$sid)"
+$sddl = ((& sc.exe sdshow scmanager) | Where-Object { $_ -match '\S' }) -join ''
+if ($sddl -notmatch '^D:') { throw "Kunne ikke læse Service Control Managers sikkerhed: $sddl" }
+$backup = Join-Path $env:ProgramData 'SQLInsight\scmanager-sddl-backup.txt'
+if (-not (Test-Path $backup)) {
+ New-Item -ItemType Directory -Force -Path (Split-Path $backup) | Out-Null
+ Set-Content -Path $backup -Value $sddl
+}
+$clean = $sddl.Replace($scmAce, '')
+if ($Remove) {
+ $new = $clean
+} else {
+ $at = $clean.IndexOf('S:')
+ if ($at -lt 0) { $new = $clean + $scmAce } else { $new = $clean.Substring(0, $at) + $scmAce + $clean.Substring($at) }
+}
+if ($new -ne $sddl) {
+ & sc.exe sdset scmanager $new | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "sc.exe sdset scmanager fejlede ($LASTEXITCODE). Den oprindelige SDDL står i $backup" }
+ if ($Remove) { Write-Host ' - Service Control Manager' } else { Write-Host ' + Service Control Manager (CC LC RP RC: forbinde, liste, læse)' }
+} else {
+ Write-Host ' = Service Control Manager: uændret'
+}
+
+# 4. Firewall (valgfrit): Windows' indbyggede regler, fundet på deres faste (ikke oversatte) navne.
+if ($EnableFirewall -and -not $Remove) {
+ if (-not (Get-Command Enable-NetFirewallRule -ErrorAction SilentlyContinue)) {
+ Write-Host ' - Enable-NetFirewallRule findes ikke (før Windows Server 2012): slå reglerne for WMI og Remote Event Log Management til manuelt'
+ } else {
+ foreach ($rule in 'WMI-WINMGMT-In-TCP', 'WMI-RPCSS-In-TCP', 'WMI-ASYNC-In-TCP', 'WINRM-HTTP-In-TCP',
+ 'RemoteEventLogSvc-In-TCP', 'RemoteEventLogSvc-NP-In-TCP', 'RemoteEventLogSvc-RPCSS-In-TCP') {
+ $found = Get-NetFirewallRule -Name $rule -ErrorAction SilentlyContinue
+ if (-not $found) {
+ Write-Host " - firewallreglen $rule findes ikke; springes over"
+ continue
+ }
+ if ($AgentAddress) { $found | Set-NetFirewallRule -RemoteAddress $AgentAddress }
+ $found | Enable-NetFirewallRule
+ if ($AgentAddress) { Write-Host " + firewall: $rule (kun fra $AgentAddress)" } else { Write-Host " + firewall: $rule" }
+ }
+ }
+}
+
+Write-Host 'Færdig. Test forbindelsen fra SQLInsight (Servere > serveren > Test forbindelsen).'
diff --git a/src/SQLInsight.Core/SQLInsight.Core.csproj b/src/SQLInsight.Core/SQLInsight.Core.csproj
index 6133836..bebfc95 100644
--- a/src/SQLInsight.Core/SQLInsight.Core.csproj
+++ b/src/SQLInsight.Core/SQLInsight.Core.csproj
@@ -7,5 +7,7 @@
+
+
diff --git a/src/SQLInsight.Core/Servers/DiskForecast.cs b/src/SQLInsight.Core/Servers/DiskForecast.cs
new file mode 100644
index 0000000..f6fdc27
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/DiskForecast.cs
@@ -0,0 +1,135 @@
+using System.Globalization;
+
+namespace SQLInsight.Core.Servers;
+
+/// A volume's free space at one time, from SQLInsight's own samples.
+public sealed record DiskHistoryPoint(DateTimeOffset At, double SizeGb, double FreeGb);
+
+/// When a volume will be full if it keeps filling at the rate of the last weeks.
+/// Null when the volume is not filling up (or the trend is too unclear to say).
+/// How fast the used space grows (positive: filling up).
+/// R² of the trend line: 1 is a straight line, near 0 no trend.
+/// Why there is no forecast (Danish), when there is none.
+public sealed record DiskForecast(string Volume, double? DaysUntilFull, double GrowthGbPerDay, int Samples, double HistoryDays, double Fit, string? Note = null)
+{
+ public DateTimeOffset? FullAt(DateTimeOffset now) => DaysUntilFull is { } days ? now.AddDays(days) : null;
+
+ /// "fuld om ca. 12 dage" (Danish), or the note.
+ public string Describe() => DaysUntilFull switch
+ {
+ null => Note ?? "fyldes ikke op",
+ < 1 => "fuld inden for et døgn",
+ { } d => string.Create(CultureInfo.InvariantCulture, $"fuld om ca. {Math.Round(d):0} dage"),
+ };
+}
+
+public sealed record DiskForecastSettings
+{
+ /// How far back the trend is computed.
+ public int HistoryDays { get; init; } = 14;
+
+ /// Less history than this gives no forecast: a few hours say nothing about weeks.
+ public double MinimumHistoryDays { get; init; } = 2;
+
+ public int MinimumSamples { get; init; } = 12;
+
+ /// R² below this: the free space jumps up and down (backups written and deleted …) and a straight line says nothing.
+ public double MinimumFit { get; init; } = 0.5;
+
+ /// A forecast further away than this is not one.
+ public double MaximumDays { get; init; } = 365;
+
+ /// Growth slower than this (GB per day) is noise.
+ public double MinimumGrowthGbPerDay { get; init; } = 0.01;
+}
+
+///
+/// "Disken er fuld om ca. N dage": a least-squares line through the free space of the last two weeks. Only the history
+/// since the volume last changed size counts (an extended disk starts a new trend). The line must fit reasonably well
+/// (R²), cover at least a couple of days, and point down; otherwise there is no forecast, rather than a scary guess.
+///
+public static class DiskForecaster
+{
+ public static DiskForecast Forecast(string volume, IReadOnlyList history, DateTimeOffset now, DiskForecastSettings? settings = null)
+ {
+ ArgumentNullException.ThrowIfNull(volume);
+ ArgumentNullException.ThrowIfNull(history);
+ settings ??= new DiskForecastSettings();
+ var points = history.Where(p => p.At <= now && p.At >= now.AddDays(-settings.HistoryDays) && p.SizeGb > 0)
+ .OrderBy(p => p.At).ToList();
+ if (points.Count == 0)
+ {
+ return new DiskForecast(volume, null, 0, 0, 0, 0, "ingen målinger endnu");
+ }
+
+ // A volume that was extended (or shrunk) starts a new trend.
+ var latestSize = points[^1].SizeGb;
+ var start = points.FindLastIndex(p => Math.Abs(p.SizeGb - latestSize) > Math.Max(0.5, latestSize * 0.01)) + 1;
+ points = points.Skip(start).ToList();
+ var span = (points[^1].At - points[0].At).TotalDays;
+ if (points.Count < settings.MinimumSamples || span < settings.MinimumHistoryDays)
+ {
+ return new DiskForecast(volume, null, 0, points.Count, span, 0,
+ string.Create(CultureInfo.InvariantCulture, $"for lidt historik til en prognose ({span:0.#} af {settings.MinimumHistoryDays:0.#} dage)"));
+ }
+
+ var (slope, fit) = Regression(points);
+ var growth = -slope;
+ var free = points[^1].FreeGb;
+ if (free <= 0)
+ {
+ return new DiskForecast(volume, 0, growth, points.Count, span, fit);
+ }
+
+ if (growth < settings.MinimumGrowthGbPerDay)
+ {
+ return new DiskForecast(volume, null, growth, points.Count, span, fit, "fyldes ikke op");
+ }
+
+ if (fit < settings.MinimumFit)
+ {
+ return new DiskForecast(volume, null, growth, points.Count, span, fit, "ingen tydelig tendens (pladsen svinger)");
+ }
+
+ var days = free / growth;
+ return days > settings.MaximumDays
+ ? new DiskForecast(volume, null, growth, points.Count, span, fit, "fuld om mere end et år")
+ : new DiskForecast(volume, days, growth, points.Count, span, fit);
+ }
+
+ /// Slope (GB per day) and R² of free space over time.
+ public static (double Slope, double Fit) Regression(IReadOnlyList points)
+ {
+ ArgumentNullException.ThrowIfNull(points);
+ if (points.Count < 2)
+ {
+ return (0, 0);
+ }
+
+ var origin = points[0].At;
+ var xs = points.Select(p => (p.At - origin).TotalDays).ToArray();
+ var ys = points.Select(p => p.FreeGb).ToArray();
+ var meanX = xs.Average();
+ var meanY = ys.Average();
+ double sxy = 0, sxx = 0, syy = 0;
+ for (var i = 0; i < xs.Length; i++)
+ {
+ var dx = xs[i] - meanX;
+ var dy = ys[i] - meanY;
+ sxy += dx * dy;
+ sxx += dx * dx;
+ syy += dy * dy;
+ }
+
+ if (sxx <= 0)
+ {
+ return (0, 0);
+ }
+
+ var slope = sxy / sxx;
+
+ // A perfectly flat line explains everything there is to explain.
+ var fit = syy <= 1e-12 ? 1 : (sxy * sxy) / (sxx * syy);
+ return (slope, fit);
+ }
+}
diff --git a/src/SQLInsight.Core/Servers/ServerConfiguration.cs b/src/SQLInsight.Core/Servers/ServerConfiguration.cs
new file mode 100644
index 0000000..4f3ecf8
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/ServerConfiguration.cs
@@ -0,0 +1,162 @@
+using System.Globalization;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace SQLInsight.Core.Servers;
+
+/// What a Windows server is used for: shown in the UI and used to pick sensible defaults (e.g. the AOS service).
+public static class ServerRoles
+{
+ public const string Sql = "Sql";
+ public const string Application = "Application";
+ public const string File = "File";
+ public const string Other = "Other";
+
+ public static readonly IReadOnlyList All = [Sql, Application, File, Other];
+
+ public static bool IsKnown(string? role) => role is not null && All.Contains(role, StringComparer.Ordinal);
+
+ public static string Display(string? role) => role switch
+ {
+ Sql => "SQL Server",
+ Application => "Applikationsserver (AOS)",
+ File => "Filserver",
+ _ => "Anden server",
+ };
+}
+
+/// Where a Windows server in insight.WindowsServers comes from.
+public static class ServerSources
+{
+ /// Added in SQLInsight Web or API v1.
+ public const string Web = "Web";
+
+ /// The computer behind a monitored SQL Server instance: added and removed by the agent.
+ public const string SqlHost = "SqlHost";
+}
+
+/// How a Windows server is addressed: a computer name, a DNS name or an IP address. No instance, no port.
+public static partial class ServerHost
+{
+ public const int MaxLength = 255;
+
+ /// Why cannot be used (Danish), or null when it can.
+ public static string? Validate(string? host)
+ {
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ return "Angiv serverens navn, fx aos01 eller fil01.firma.local.";
+ }
+
+ var trimmed = host.Trim();
+ if (trimmed.Length > MaxLength)
+ {
+ return $"Servernavnet må højst være {MaxLength} tegn.";
+ }
+
+ if (IPAddress.TryParse(trimmed, out _) && trimmed.Contains(':', StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ return HostPattern().IsMatch(trimmed)
+ ? null
+ : "Servernavnet må kun indeholde bogstaver, tal, punktum og bindestreg (fx aos01.firma.local) – uden instansnavn og port.";
+ }
+
+ /// The form hosts are compared and stored in: trimmed, lower case.
+ public static string Normalize(string host)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ return host.Trim().TrimEnd('.').ToLowerInvariant();
+ }
+
+ /// The short computer name of a host (aos01.firma.local → AOS01); an IP address stays as it is.
+ public static string ShortName(string host)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ var trimmed = host.Trim();
+ return IPAddress.TryParse(trimmed, out _) ? trimmed : trimmed.Split('.')[0].ToUpperInvariant();
+ }
+
+ ///
+ /// The computer behind a SQL Server data source (tcp:sql01.firma.local\PROD,1433 → sql01.firma.local),
+ /// or why it is not a Windows server the agent can look at (Danish). Aliases for the local computer become
+ /// .
+ ///
+ public static (string? Host, string? Skipped) FromDataSource(string dataSource, string localMachineName)
+ {
+ ArgumentNullException.ThrowIfNull(dataSource);
+ ArgumentException.ThrowIfNullOrWhiteSpace(localMachineName);
+ var host = ProtocolPrefix().Replace(dataSource.Trim(), string.Empty).Split('\\', ',')[0].Trim();
+ if (host.Length == 0 || host is "." or "(local)" || host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || host is "127.0.0.1" or "::1")
+ {
+ return (Normalize(localMachineName), null);
+ }
+
+ if (host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase))
+ {
+ return (null, "LocalDB er ikke en server.");
+ }
+
+ if (IsAzure(host))
+ {
+ return (null, "Azure SQL Database og Managed Instance har ingen Windows-server, SQLInsight kan se.");
+ }
+
+ return Validate(host) is null ? (Normalize(host), null) : (null, "Servernavnet kan ikke bruges som Windows-server.");
+ }
+
+ public static bool IsAzure(string host)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ var h = host.Trim().TrimEnd('.');
+ return h.EndsWith(".database.windows.net", StringComparison.OrdinalIgnoreCase)
+ || h.EndsWith(".database.azure.com", StringComparison.OrdinalIgnoreCase)
+ || h.EndsWith(".database.usgovcloudapi.net", StringComparison.OrdinalIgnoreCase)
+ || h.EndsWith(".sql.azuresynapse.net", StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Whether is the agent's own computer (then collection needs no network).
+ public static bool IsLocal(string host, string machineName)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ ArgumentNullException.ThrowIfNull(machineName);
+ var h = host.Trim();
+ return h is "." or "127.0.0.1" or "::1"
+ || h.Equals("localhost", StringComparison.OrdinalIgnoreCase)
+ || h.Equals(machineName, StringComparison.OrdinalIgnoreCase)
+ || h.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// A comma- or line-separated list of service or task names, trimmed and without duplicates.
+ public static IReadOnlyList ParseList(string? text) =>
+ string.IsNullOrWhiteSpace(text)
+ ? []
+ : text.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ /// Why a list of service names cannot be used (Danish), or null.
+ public static string? ValidateServiceNames(IEnumerable names)
+ {
+ ArgumentNullException.ThrowIfNull(names);
+ var list = names.ToList();
+ if (list.Count > 100)
+ {
+ return "Højst 100 tjenester i en liste.";
+ }
+
+ return list.FirstOrDefault(n => n.Length > 256 || !ServiceNamePattern().IsMatch(n)) is { } bad
+ ? string.Create(CultureInfo.InvariantCulture, $"\"{bad}\" er ikke et gyldigt tjenestenavn (brug tjenestens navn, fx MSSQLSERVER eller AOS60$01).")
+ : null;
+ }
+
+ [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9\-]{0,62})(\.[A-Za-z0-9]([A-Za-z0-9\-]{0,62}))*\.?$")]
+ private static partial Regex HostPattern();
+
+ [GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)]
+ private static partial Regex ProtocolPrefix();
+
+ [GeneratedRegex(@"^[^\\/""\x00-\x1f]+$")]
+ private static partial Regex ServiceNamePattern();
+}
diff --git a/src/SQLInsight.Core/Servers/ServerEvents.cs b/src/SQLInsight.Core/Servers/ServerEvents.cs
new file mode 100644
index 0000000..26aabab
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/ServerEvents.cs
@@ -0,0 +1,233 @@
+namespace SQLInsight.Core.Servers;
+
+/// What kind of Windows event this is, for the rules. Ordered by how much it matters (most last).
+public enum ServerEventCategory
+{
+ /// An error with nothing special about it: only reported when it repeats.
+ Error,
+
+ /// An application or the AOS crashed or hung (Application Error 1000, .NET Runtime 1026 …).
+ ApplicationCrash,
+
+ /// Errors from Dynamics AX's Application Object Server.
+ DynamicsAx,
+
+ /// A service stopped unexpectedly or could not start (Service Control Manager 7031, 7034, 7000 …).
+ ServiceFailure,
+
+ /// Level Critical with no more specific category.
+ Critical,
+
+ /// Out of memory or another resource (srv 2019/2020, Resource-Exhaustion-Detector 2004).
+ Resources,
+
+ /// Disk, controller or file system errors (disk 7/11/51/153, storport 129, Ntfs 55/98).
+ Disk,
+
+ /// The server went down without being shut down (Kernel-Power 41, EventLog 6008, a blue screen).
+ UnexpectedShutdown,
+}
+
+/// An event from the System or Application log. Level: 1 critical, 2 error.
+/// Can name users, computers and paths: sensitive.
+public sealed record ServerEventEntry(string Log, string Source, long EventId, int Level, DateTimeOffset At, string? Message = null, long? RecordId = null);
+
+/// The same event repeated, summed: one line per log, source and event id.
+public sealed record ServerEventGroup(string Log, string Source, long EventId, int Level, ServerEventCategory Category, int Count, DateTimeOffset FirstAt,
+ DateTimeOffset LastAt, string? Sample)
+{
+ public string Key => Log + "/" + Source + "/" + EventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
+}
+
+public sealed record ServerEventSnapshot
+{
+ public required DateTimeOffset CapturedAt { get; init; }
+
+ /// The newest event read; the next collection continues after it.
+ public required DateTimeOffset ReadUntil { get; init; }
+
+ /// How the log was read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)".
+ public string? Method { get; init; }
+
+ /// What was found since the previous collection, grouped (noise left out).
+ public IReadOnlyList Groups { get; init; } = [];
+
+ /// The groups of the last : they keep findings open.
+ public IReadOnlyList Recent { get; init; } = [];
+
+ /// Events left out as known noise in this collection.
+ public int Ignored { get; init; }
+
+ /// Why less is seen than the logs have (Danish), e.g. the collection was capped.
+ public string? Coverage { get; init; }
+}
+
+///
+/// Windows' event logs, summed like SQL Server's error log (1.2): known noise is left out, the rest is grouped per
+/// source and event id, and a group keeps its finding open for a day after it was last seen.
+///
+public static class ServerEventAnalysis
+{
+ public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
+
+ /// The logs that are read.
+ public static readonly IReadOnlyList Logs = ["System", "Application"];
+
+ ///
+ /// Noise left out unless SQLInsight:Servers:EventLog:Ignore says otherwise: well-known events that are errors in name only,
+ /// and SQL Server's own events, which the SQL Server error log monitoring already covers. "Kilde:id", "Kilde" or "Kilde*".
+ ///
+ public static readonly IReadOnlyList DefaultIgnore =
+ [
+ "Microsoft-Windows-DistributedCOM:10016",
+ "DCOM:10016",
+ "Microsoft-Windows-DistributedCOM:10010",
+ "DCOM:10010",
+ "Schannel:36874",
+ "Schannel:36887",
+ "Schannel:36888",
+ "Microsoft-Windows-TPM-WMI:1796",
+ "Microsoft-Windows-TPM-WMI:1801",
+ "Microsoft-Windows-WMI:10",
+ "Microsoft-Windows-Security-SPP:8198",
+ "MSSQLSERVER",
+ "MSSQL$*",
+ "SQLSERVERAGENT",
+ "SQLAgent$*",
+ "SQLISPackage*",
+ ];
+
+ public static ServerEventCategory Categorize(ServerEventEntry entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ var source = entry.Source;
+ bool Is(params string[] names) => names.Any(n => source.Equals(n, StringComparison.OrdinalIgnoreCase));
+ return entry.EventId switch
+ {
+ 41 when Is("Microsoft-Windows-Kernel-Power") => ServerEventCategory.UnexpectedShutdown,
+ 6008 when Is("EventLog") => ServerEventCategory.UnexpectedShutdown,
+ 1001 when Is("BugCheck", "Microsoft-Windows-WER-SystemErrorReporting") => ServerEventCategory.UnexpectedShutdown,
+ 7 or 11 or 51 or 153 when Is("disk", "Disk") => ServerEventCategory.Disk,
+ 129 when source.Contains("stor", StringComparison.OrdinalIgnoreCase) || Is("iaStorA", "LSI_SAS", "vhdmp") => ServerEventCategory.Disk,
+ 55 or 98 or 137 when Is("Ntfs", "Microsoft-Windows-Ntfs") => ServerEventCategory.Disk,
+ 157 when Is("disk", "Disk") => ServerEventCategory.Disk,
+ 2019 or 2020 when Is("Srv") => ServerEventCategory.Resources,
+ 2004 when Is("Microsoft-Windows-Resource-Exhaustion-Detector") => ServerEventCategory.Resources,
+ 7000 or 7001 or 7009 or 7011 or 7022 or 7023 or 7024 or 7031 or 7032 or 7034 when Is("Service Control Manager") => ServerEventCategory.ServiceFailure,
+ 1000 or 1002 when Is("Application Error", "Application Hang") => ServerEventCategory.ApplicationCrash,
+ 1026 when Is(".NET Runtime") => ServerEventCategory.ApplicationCrash,
+ _ when source.StartsWith("Microsoft Dynamics AX", StringComparison.OrdinalIgnoreCase)
+ || source.StartsWith("Dynamics Server", StringComparison.OrdinalIgnoreCase)
+ || source.StartsWith("Dynamics AX", StringComparison.OrdinalIgnoreCase) => ServerEventCategory.DynamicsAx,
+ _ when entry.Level == 1 => ServerEventCategory.Critical,
+ _ => ServerEventCategory.Error,
+ };
+ }
+
+ /// Whether matches an ignore entry ("Kilde:id", "Kilde", "Kilde*", "Kilde*:id").
+ public static bool IsIgnored(ServerEventEntry entry, IEnumerable ignore)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentNullException.ThrowIfNull(ignore);
+ foreach (var raw in ignore)
+ {
+ var item = raw?.Trim();
+ if (string.IsNullOrEmpty(item))
+ {
+ continue;
+ }
+
+ var colon = item.LastIndexOf(':');
+ var source = colon > 0 ? item[..colon] : item;
+ var id = colon > 0 ? item[(colon + 1)..] : null;
+ var sourceMatches = source.EndsWith('*')
+ ? entry.Source.StartsWith(source[..^1], StringComparison.OrdinalIgnoreCase)
+ : entry.Source.Equals(source, StringComparison.OrdinalIgnoreCase);
+ if (sourceMatches && (id is null or "*" || long.TryParse(id, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var number) && number == entry.EventId))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// Sums repeated events: one group per log, source and event id, with the newest message as a sample. Noise is counted, not grouped.
+ public static (IReadOnlyList Groups, int Ignored) Group(IEnumerable entries, IEnumerable ignore)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ ArgumentNullException.ThrowIfNull(ignore);
+ var ignoreList = ignore.ToList();
+ var ignored = 0;
+ var kept = new List();
+ foreach (var entry in entries.Where(e => e.Level is 1 or 2))
+ {
+ if (IsIgnored(entry, ignoreList))
+ {
+ ignored++;
+ }
+ else
+ {
+ kept.Add(entry);
+ }
+ }
+
+ var groups = kept
+ .GroupBy(e => (Log: e.Log.ToUpperInvariant(), Source: e.Source.ToUpperInvariant(), e.EventId))
+ .Select(g =>
+ {
+ var newest = g.OrderByDescending(e => e.At).First();
+ return new ServerEventGroup(newest.Log, newest.Source, newest.EventId, g.Min(e => e.Level), Categorize(newest), g.Count(),
+ g.Min(e => e.At), g.Max(e => e.At), Cut(g.OrderByDescending(e => e.At).Select(e => e.Message).FirstOrDefault(m => !string.IsNullOrWhiteSpace(m))));
+ })
+ .OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
+ .ToList();
+ return (groups, ignored);
+ }
+
+ /// The groups of the last day: this collection's plus the earlier ones still in the window, merged.
+ public static IReadOnlyList Recent(IReadOnlyList previous, IReadOnlyList current, DateTimeOffset now)
+ {
+ ArgumentNullException.ThrowIfNull(previous);
+ ArgumentNullException.ThrowIfNull(current);
+ return previous.Where(g => now - g.LastAt < RecentWindow).Concat(current)
+ .GroupBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(g =>
+ {
+ var newest = g.OrderByDescending(x => x.LastAt).First();
+ return newest with
+ {
+ Level = g.Min(x => x.Level),
+ Count = g.Sum(x => x.Count),
+ FirstAt = g.Min(x => x.FirstAt),
+ LastAt = g.Max(x => x.LastAt),
+ Sample = g.OrderByDescending(x => x.LastAt).Select(x => x.Sample).FirstOrDefault(s => s is not null),
+ };
+ })
+ .OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
+ .ToList();
+ }
+
+ public static string Display(ServerEventCategory category) => category switch
+ {
+ ServerEventCategory.UnexpectedShutdown => "Uventet nedlukning",
+ ServerEventCategory.Disk => "Disk og filsystem",
+ ServerEventCategory.Resources => "Ressourcer opbrugt",
+ ServerEventCategory.Critical => "Kritisk hændelse",
+ ServerEventCategory.ServiceFailure => "Tjeneste stoppet eller startede ikke",
+ ServerEventCategory.DynamicsAx => "Dynamics AX (AOS)",
+ ServerEventCategory.ApplicationCrash => "Program gik ned",
+ _ => "Fejl",
+ };
+
+ private static string? Cut(string? text)
+ {
+ if (text is null)
+ {
+ return null;
+ }
+
+ var trimmed = text.Trim();
+ return trimmed.Length > 500 ? trimmed[..500] : trimmed;
+ }
+}
diff --git a/src/SQLInsight.Core/Servers/ServerGrantScript.cs b/src/SQLInsight.Core/Servers/ServerGrantScript.cs
new file mode 100644
index 0000000..774e0d4
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/ServerGrantScript.cs
@@ -0,0 +1,51 @@
+using System.Text.RegularExpressions;
+
+namespace SQLInsight.Core.Servers;
+
+///
+/// db/permissions/grant-windows-server.ps1, ready to run for one account: what SQLInsight Web shows for a Windows
+/// server. SQLInsight never runs it itself; an administrator on the server does.
+///
+public static partial class ServerGrantScript
+{
+ /// The script with as the default for -Account.
+ public static string For(string account)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(account);
+ var literal = "'" + account.Trim().Replace("'", "''", StringComparison.Ordinal) + "'";
+ return AccountPlaceholder().Replace(Template(), _ => "[string] $Account = " + literal + ", # <== agentens konto", 1);
+ }
+
+ /// The script as shipped, with the placeholder account.
+ public static string Template()
+ {
+ using var stream = typeof(ServerGrantScript).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-windows-server.ps1")
+ ?? throw new InvalidOperationException("grant-windows-server.ps1 mangler i programmet.");
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ ///
+ /// The account the agent arrives as on : a virtual or built-in machine account becomes the
+ /// computer account DOMAIN\COMPUTER$ on any other server; a domain account or gMSA is the same everywhere.
+ ///
+ public static string AccountFor(string serviceAccount, string host, string machineName, string domain)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(serviceAccount);
+ ArgumentNullException.ThrowIfNull(host);
+ ArgumentException.ThrowIfNullOrWhiteSpace(machineName);
+ var account = serviceAccount.Trim();
+ if (account.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase))
+ {
+ account = @"NT AUTHORITY\SYSTEM";
+ }
+
+ var machineIdentity = account.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase) || account.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase);
+ return machineIdentity && !ServerHost.IsLocal(host, machineName)
+ ? $@"{(string.IsNullOrWhiteSpace(domain) ? machineName : domain)}\{machineName}$"
+ : account;
+ }
+
+ [GeneratedRegex(@"\[string\] \$Account = '[^']*',[^\r\n]*")]
+ private static partial Regex AccountPlaceholder();
+}
diff --git a/src/SQLInsight.Core/Servers/ServerPerformance.cs b/src/SQLInsight.Core/Servers/ServerPerformance.cs
new file mode 100644
index 0000000..6e8fc51
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/ServerPerformance.cs
@@ -0,0 +1,76 @@
+namespace SQLInsight.Core.Servers;
+
+/// Rates from two raw counter samples, as Performance Monitor computes them.
+public static class ServerPerformanceMath
+{
+ /// How long the per-minute samples are kept in memory for the rules.
+ public static readonly TimeSpan Window = TimeSpan.FromHours(1);
+
+ /// The interval's sample. CPU and pages/s need the previous raw sample; without it (or after a reboot) they are null.
+ public static ServerPerformanceSample Sample(ServerPerformanceRaw? previous, ServerPerformanceRaw current, ServerOsInfo os)
+ {
+ ArgumentNullException.ThrowIfNull(current);
+ ArgumentNullException.ThrowIfNull(os);
+ return new ServerPerformanceSample(
+ current.CapturedAt,
+ Cpu(previous, current),
+ current.AvailableMb,
+ os.TotalMemoryMb,
+ PagesPerSecond(previous, current),
+ current.CommittedBytes is { } committed && current.CommitLimit is > 0 ? Math.Clamp(100 * committed / current.CommitLimit.Value, 0, 100) : null);
+ }
+
+ ///
+ /// PERF_100NSEC_TIMER_INV: 100 × (1 − Δidle / Δtime). The counter is the processors' idle time (averaged over all
+ /// of them for _Total), so the result is the average CPU over the interval.
+ ///
+ public static double? Cpu(ServerPerformanceRaw? previous, ServerPerformanceRaw current)
+ {
+ ArgumentNullException.ThrowIfNull(current);
+ if (previous?.ProcessorIdle100Ns is not { } idle0 || previous.ProcessorTimestamp100Ns is not { } time0
+ || current.ProcessorIdle100Ns is not { } idle1 || current.ProcessorTimestamp100Ns is not { } time1
+ || time1 <= time0 || idle1 < idle0)
+ {
+ return null;
+ }
+
+ var busy = 100 * (1 - ((double)(idle1 - idle0) / (time1 - time0)));
+ return Math.Clamp(busy, 0, 100);
+ }
+
+ /// PERF_COUNTER_COUNTER: Δcount / (Δtime / frequency).
+ public static double? PagesPerSecond(ServerPerformanceRaw? previous, ServerPerformanceRaw current)
+ {
+ ArgumentNullException.ThrowIfNull(current);
+ if (previous?.Pages is not { } pages0 || previous.MemoryPerfTime is not { } time0
+ || current.Pages is not { } pages1 || current.MemoryPerfTime is not { } time1 || current.MemoryPerfFrequency is not ({ } frequency and > 0)
+ || time1 <= time0 || pages1 < pages0)
+ {
+ return null;
+ }
+
+ return (pages1 - pages0) / ((double)(time1 - time0) / frequency);
+ }
+
+ /// The window's samples after adding ; older ones fall out.
+ public static IReadOnlyList Append(IReadOnlyList samples, ServerPerformanceSample sample)
+ {
+ ArgumentNullException.ThrowIfNull(samples);
+ ArgumentNullException.ThrowIfNull(sample);
+ return samples.Where(s => s.At > sample.At - Window && s.At < sample.At).Append(sample).ToList();
+ }
+
+ ///
+ /// The average over the last , when the samples cover at least 80 % of it (so a short
+ /// burst after a gap is not "sustained"). Null otherwise.
+ ///
+ public static double? SustainedAverage(IReadOnlyList samples, Func value, TimeSpan period, DateTimeOffset now,
+ TimeSpan interval)
+ {
+ ArgumentNullException.ThrowIfNull(samples);
+ ArgumentNullException.ThrowIfNull(value);
+ var values = samples.Where(s => s.At > now - period && s.At <= now).Select(value).OfType().ToList();
+ var expected = Math.Max(1, (int)Math.Floor(period / (interval <= TimeSpan.Zero ? TimeSpan.FromMinutes(1) : interval)));
+ return values.Count >= Math.Ceiling(expected * 0.8) ? values.Average() : null;
+ }
+}
diff --git a/src/SQLInsight.Core/Servers/ServerRequests.cs b/src/SQLInsight.Core/Servers/ServerRequests.cs
new file mode 100644
index 0000000..53572bc
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/ServerRequests.cs
@@ -0,0 +1,51 @@
+namespace SQLInsight.Core.Servers;
+
+///
+/// What SQLInsight Web asks the agent to do about Windows servers and synthetic checks (insight.AgentRequests, with the
+/// server or check in TargetId). They run in the agent, as the agent's identity, like the SQL Server connection tests.
+///
+public static class ServerRequestKind
+{
+ /// Connect to a Windows server and check which areas the agent may read.
+ public const string ServerTest = "ServerTest";
+
+ /// Run a synthetic check now and store the result.
+ public const string SyntheticCheckRun = "SyntheticCheckRun";
+
+ public static readonly IReadOnlyList All = [ServerTest, SyntheticCheckRun];
+
+ public static bool Handles(string kind) => All.Contains(kind, StringComparer.Ordinal);
+}
+
+/// Whether the agent can read one area of a Windows server, and if not, exactly why (Danish).
+/// What gives the agent the area (from the grant script).
+public sealed record ServerAreaStatus(string Area, bool Ok, string? Detail, string Right);
+
+/// The outcome of a Windows server test, as the agent saw it.
+public sealed record WindowsServerTestResult
+{
+ public bool Reachable { get; init; }
+
+ /// "WS-Man", "DCOM" or "lokal".
+ public string? Protocol { get; init; }
+
+ public string? Error { get; init; }
+
+ /// What to do about (Danish).
+ public string? Hint { get; init; }
+
+ /// The account the agent connected as (the one the grant script is for).
+ public string? Account { get; init; }
+
+ public string? ComputerName { get; init; }
+ public string? OperatingSystem { get; init; }
+ public string? Version { get; init; }
+ public DateTimeOffset? LastBootUpTime { get; init; }
+
+ public IReadOnlyList Areas { get; init; } = [];
+
+ /// Notes about the result (Danish), e.g. why WS-Man was not used.
+ public IReadOnlyList Notes { get; init; } = [];
+
+ public IEnumerable Missing => Areas.Where(a => !a.Ok);
+}
diff --git a/src/SQLInsight.Core/Servers/ServerTasks.cs b/src/SQLInsight.Core/Servers/ServerTasks.cs
new file mode 100644
index 0000000..933c45a
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/ServerTasks.cs
@@ -0,0 +1,51 @@
+using System.Globalization;
+
+namespace SQLInsight.Core.Servers;
+
+/// Task Scheduler's result codes (LastTaskResult) in Danish, and which of them mean the task failed.
+public static class TaskResults
+{
+ public const uint Success = 0;
+ public const uint Ready = 0x41300;
+ public const uint Running = 0x41301;
+ public const uint Disabled = 0x41302;
+ public const uint HasNotRun = 0x41303;
+ public const uint NoMoreRuns = 0x41304;
+ public const uint TerminatedByUser = 0x41306;
+ public const uint Queued = 0x41325;
+ public const uint AlreadyRunning = 0x8004131F;
+
+ /// Whether the last run ended badly. Running, queued, never run and "already running" are not failures.
+ public static bool IsFailure(uint? result) => result is { } r && r is not (Success or Ready or Running or Disabled or HasNotRun or NoMoreRuns or Queued or AlreadyRunning);
+
+ public static string Hex(uint result) => "0x" + result.ToString("X", CultureInfo.InvariantCulture);
+
+ public static string Describe(uint result) => result switch
+ {
+ Success => "Fuldført (0x0)",
+ Ready => "Klar (0x41300)",
+ Running => "Kører nu (0x41301)",
+ Disabled => "Deaktiveret (0x41302)",
+ HasNotRun => "Har ikke kørt endnu (0x41303)",
+ NoMoreRuns => "Ingen flere planlagte kørsler (0x41304)",
+ TerminatedByUser => "Afbrudt af en bruger eller fordi den kørte for længe (0x41306)",
+ Queued => "I kø (0x41325)",
+ AlreadyRunning => "Kørte allerede, en ny kørsel blev ikke startet (0x8004131F)",
+ 0x1 => "Programmet sluttede med fejlkode 1 (typisk en fejl i scriptet eller programmet) (0x1)",
+ 0x2 => "Filen blev ikke fundet (0x2)",
+ 0xC000013A => "Programmet blev afbrudt (Ctrl+C eller nedlukning) (0xC000013A)",
+ 0x80070002 => "Filen blev ikke fundet (0x80070002)",
+ 0x80070005 => "Adgang nægtet: kontoen, opgaven kører som, mangler rettigheder (0x80070005)",
+ 0x8007052E => "Logon fejlede: forkert brugernavn eller adgangskode til opgavens konto (0x8007052E)",
+ 0x800710E0 => "Opgaven blev afvist (fx fordi computeren kørte på batteri eller kontoen ikke var logget på) (0x800710E0)",
+ 0x80041326 => "Opgaven er deaktiveret (0x80041326)",
+ _ => "Sluttede med fejlkode " + Hex(result),
+ };
+
+ /// Whether a task is Microsoft's own (\Microsoft\…), which is not watched unless asked for.
+ public static bool IsMicrosoftTask(string path)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+ return path.StartsWith(@"\Microsoft\", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/SQLInsight.Core/Servers/WindowsServerData.cs b/src/SQLInsight.Core/Servers/WindowsServerData.cs
new file mode 100644
index 0000000..92db927
--- /dev/null
+++ b/src/SQLInsight.Core/Servers/WindowsServerData.cs
@@ -0,0 +1,188 @@
+namespace SQLInsight.Core.Servers;
+
+///
+/// What the agent collects from a Windows server, and how often by default. Each area is collected on its own, so a
+/// missing right only makes that area "ikke målbar".
+///
+public static class ServerArea
+{
+ /// CPU, memory, paging and uptime (Win32_PerfRawData_PerfOS_*, Win32_OperatingSystem).
+ public const string Performance = "performance";
+
+ /// Free space per volume (Win32_Volume, or Win32_LogicalDisk).
+ public const string Disks = "disks";
+
+ /// Services and their state (Win32_Service).
+ public const string Services = "services";
+
+ /// Errors and critical events in the System and Application logs.
+ public const string Events = "events";
+
+ /// Scheduled tasks and their last result (root\Microsoft\Windows\TaskScheduler).
+ public const string Tasks = "tasks";
+
+ public static readonly IReadOnlyList All = [Performance, Disks, Services, Events, Tasks];
+
+ public static TimeSpan DefaultInterval(string area) => area switch
+ {
+ Performance => TimeSpan.FromMinutes(1),
+ Disks => TimeSpan.FromMinutes(15),
+ Services => TimeSpan.FromMinutes(5),
+ Events => TimeSpan.FromMinutes(5),
+ Tasks => TimeSpan.FromMinutes(15),
+ _ => TimeSpan.FromMinutes(15),
+ };
+
+ public static string Display(string area) => area switch
+ {
+ Performance => "CPU, hukommelse og oppetid",
+ Disks => "Diske",
+ Services => "Tjenester",
+ Events => "Hændelseslog",
+ Tasks => "Planlagte opgaver",
+ _ => area,
+ };
+
+ /// The Windows right that opens the area to the agent (Danish), as in the grant script.
+ public static string Right(string area) => area switch
+ {
+ Performance => "Performance Monitor Users + WMI Remote Enable på root\\cimv2",
+ Disks => "WMI Remote Enable på root\\cimv2",
+ Services => "WMI Remote Enable på root\\cimv2 + læseret på Service Control Manager",
+ Events => "Event Log Readers",
+ Tasks => "WMI Remote Enable på root\\Microsoft\\Windows\\TaskScheduler",
+ _ => string.Empty,
+ };
+}
+
+/// The operating system as Win32_OperatingSystem reports it.
+public sealed record ServerOsInfo
+{
+ public string? Caption { get; init; }
+ public string? Version { get; init; }
+ public string? BuildNumber { get; init; }
+ public DateTimeOffset? LastBootUpTime { get; init; }
+ public double? TotalMemoryMb { get; init; }
+ public int? LogicalProcessors { get; init; }
+
+ /// The computer's own name (CSName).
+ public string? ComputerName { get; init; }
+}
+
+///
+/// Raw performance counters as one CIM query returns them. Rates (CPU, pages/s) are computed between two of these,
+/// so a value always covers the whole interval, not the instant of the query.
+///
+public sealed record ServerPerformanceRaw
+{
+ public required DateTimeOffset CapturedAt { get; init; }
+
+ /// Win32_PerfRawData_PerfOS_Processor(_Total).PercentProcessorTime: idle time in 100 ns (PERF_100NSEC_TIMER_INV).
+ public ulong? ProcessorIdle100Ns { get; init; }
+
+ /// The processor counter's Timestamp_Sys100NS.
+ public ulong? ProcessorTimestamp100Ns { get; init; }
+
+ /// Win32_PerfRawData_PerfOS_Memory.PagesPersec (PERF_COUNTER_COUNTER).
+ public ulong? Pages { get; init; }
+
+ public ulong? MemoryPerfTime { get; init; }
+ public ulong? MemoryPerfFrequency { get; init; }
+ public double? AvailableMb { get; init; }
+ public double? CommittedBytes { get; init; }
+ public double? CommitLimit { get; init; }
+}
+
+/// One minute (or interval) of a server's CPU and memory.
+/// Average CPU over the interval since the previous raw sample; null for the first one.
+public sealed record ServerPerformanceSample(DateTimeOffset At, double? CpuPercent, double? AvailableMb, double? TotalMb, double? PagesPerSecond, double? CommitPercent)
+{
+ public double? AvailablePercent => AvailableMb is { } a && TotalMb is > 0 ? 100 * a / TotalMb.Value : null;
+}
+
+public sealed record ServerPerformanceSnapshot
+{
+ public required DateTimeOffset CapturedAt { get; init; }
+ public required ServerOsInfo Os { get; init; }
+ public ServerPerformanceRaw? Raw { get; init; }
+
+ /// The samples of the last hour, oldest first (sustained CPU, low memory, paging).
+ public IReadOnlyList Samples { get; init; } = [];
+
+ /// The boot time seen at the previous collection, when it was different: the server has restarted since.
+ public DateTimeOffset? PreviousBootUpTime { get; init; }
+
+ /// When the agent last saw the server restart (kept for a day, so the finding stays visible).
+ public DateTimeOffset? RestartSeenAt { get; init; }
+
+ /// "WS-Man" or "DCOM" (or "lokal").
+ public string? Protocol { get; init; }
+}
+
+/// C:\, or the mount point path.
+public sealed record ServerDisk(string Volume, string? Label, string? FileSystem, double SizeGb, double FreeGb)
+{
+ public double FreePercent => SizeGb > 0 ? 100 * FreeGb / SizeGb : 0;
+}
+
+public sealed record ServerDiskSnapshot
+{
+ public required DateTimeOffset CapturedAt { get; init; }
+ public IReadOnlyList Disks { get; init; } = [];
+
+ /// "fuld om ca. N dage" per volume, from SQLInsight's own history of the free space.
+ public IReadOnlyList Forecasts { get; init; } = [];
+}
+
+public sealed record ServerService(string Name, string DisplayName, string State, string StartMode, bool DelayedAutoStart = false, int? ExitCode = null)
+{
+ public bool IsRunning => string.Equals(State, "Running", StringComparison.OrdinalIgnoreCase);
+
+ public bool IsAutomatic => string.Equals(StartMode, "Auto", StringComparison.OrdinalIgnoreCase);
+}
+
+public sealed record ServerServiceSnapshot
+{
+ public required DateTimeOffset CapturedAt { get; init; }
+ public IReadOnlyList Services { get; init; } = [];
+}
+
+/// A scheduled task as Task Scheduler reports it.
+public sealed record ServerTask
+{
+ public required string Path { get; init; }
+ public required string Name { get; init; }
+
+ /// Ready, Running, Disabled, Queued or Unknown.
+ public string State { get; init; } = "Unknown";
+
+ public DateTimeOffset? LastRunTime { get; init; }
+ public uint? LastTaskResult { get; init; }
+ public DateTimeOffset? NextRunTime { get; init; }
+ public int MissedRuns { get; init; }
+
+ public string FullName => Path.EndsWith('\\') ? Path + Name : Path + "\\" + Name;
+
+ public bool Enabled => !string.Equals(State, "Disabled", StringComparison.OrdinalIgnoreCase);
+}
+
+public sealed record ServerTaskSnapshot
+{
+ public required DateTimeOffset CapturedAt { get; init; }
+
+ /// The tasks that are watched (by default the ones not under \Microsoft\).
+ public IReadOnlyList Tasks { get; init; } = [];
+
+ /// Every task the agent could see, Microsoft's own included.
+ public int Visible { get; init; }
+
+ /// Why less is seen than there is (Danish), e.g. tasks the agent may not read.
+ public string? Coverage { get; init; }
+}
+
+/// A configured Windows server as the rules see it.
+public sealed record ServerIdentity(int ServerId, string Name, string Host, string Role)
+{
+ public IReadOnlyList WatchedServices { get; init; } = [];
+ public IReadOnlyList IgnoredServices { get; init; } = [];
+}
diff --git a/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs b/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs
new file mode 100644
index 0000000..ec441f4
--- /dev/null
+++ b/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs
@@ -0,0 +1,334 @@
+using System.Globalization;
+using System.Net;
+using System.Text.Json;
+using SQLInsight.Core.Configuration;
+using SQLInsight.Core.CustomRules;
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Core.SyntheticChecks;
+
+/// The kinds of synthetic check: what the agent does, the way a user or an application would.
+public static class SyntheticCheckKind
+{
+ /// A TCP port answers (e.g. the AOS on 2712).
+ public const string Tcp = "Tcp";
+
+ /// An HTTP(S) GET returns the expected status (and text); the TLS certificate is checked.
+ public const string Http = "Http";
+
+ /// A SQL Server login works and a simple query answers in time.
+ public const string Sql = "Sql";
+
+ /// A name resolves (to the expected addresses).
+ public const string Dns = "Dns";
+
+ /// Dynamics AX: the AOS port answers, and a logon through the .NET Business Connector works when it is installed.
+ public const string AxLogon = "AxLogon";
+
+ public static readonly IReadOnlyList All = [Tcp, Http, Sql, Dns, AxLogon];
+
+ public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind, StringComparer.Ordinal);
+
+ public static string Display(string? kind) => kind switch
+ {
+ Tcp => "TCP-port",
+ Http => "HTTP(S)",
+ Sql => "SQL-login og forespørgsel",
+ Dns => "DNS-opslag",
+ AxLogon => "Dynamics AX-logon",
+ _ => kind ?? string.Empty,
+ };
+}
+
+///
+/// What a synthetic check does: the fields for its kind (stored as JSON in insight.SyntheticChecks.SettingsJson). A
+/// SQL login password is never here; it is sealed for the agent in the check's own row.
+///
+public sealed record SyntheticCheckSettings
+{
+ /// Tcp, Dns and AxLogon: the computer (for Dns: the name to resolve).
+ public string? Host { get; init; }
+
+ /// Tcp and AxLogon (default 2712), Sql (optional).
+ public int? Port { get; init; }
+
+ /// Http: http:// or https://.
+ public Uri? Url { get; init; }
+
+ /// Http: the status codes that count as success; empty: 200–399.
+ public IReadOnlyList ExpectedStatus { get; init; } = [];
+
+ /// Http: text the answer must contain.
+ public string? ExpectedText { get; init; }
+
+ /// Http: log on as the agent's account (Windows authentication), e.g. Enterprise Portal or Reporting Services.
+ public bool UseWindowsAuthentication { get; init; }
+
+ /// Http: accept a certificate that is not valid (self-signed …); its expiry is still checked.
+ public bool AllowInvalidCertificate { get; init; }
+
+ /// Sql: server or server\instance.
+ public string? Server { get; init; }
+
+ public string? Database { get; init; }
+
+ /// Sql: Windows (the agent's account) or SqlLogin.
+ public string Authentication { get; init; } = "Windows";
+
+ public string? UserName { get; init; }
+
+ /// Sql: Mandatory or Optional.
+ public string Encryption { get; init; } = "Mandatory";
+
+ public bool TrustServerCertificate { get; init; }
+
+ /// Sql: one read-only SELECT; default SELECT 1. Runs on SQL Server 2005 and later.
+ public string? Query { get; init; }
+
+ /// Dns: addresses the name must resolve to (any of them); empty: any answer.
+ public IReadOnlyList ExpectedAddresses { get; init; } = [];
+
+ /// AxLogon: the company to log on to (DataAreaId), e.g. DAT.
+ public string? Company { get; init; }
+
+ /// AxLogon: the Business Connector's object server, e.g. 01@aos01:2712; default host:port.
+ public string? ObjectServer { get; init; }
+
+ /// AxLogon: the AX client configuration to use; default the Business Connector's own.
+ public string? Configuration { get; init; }
+
+ /// AxLogon: try a logon through the .NET Business Connector when it is installed on the agent's computer.
+ public bool BusinessConnectorLogon { get; init; } = true;
+
+ /// The Windows service behind what is checked, on the check's server (e.g. AOS60$01, W3SVC).
+ public string? ServiceName { get; init; }
+
+ /// An answer slower than this (ms) three times in a row is a finding.
+ public int? WarnMilliseconds { get; init; }
+
+ public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = false };
+
+ public string ToJson() => JsonSerializer.Serialize(this, Json);
+
+ public static SyntheticCheckSettings FromJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return new SyntheticCheckSettings();
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, Json) ?? new SyntheticCheckSettings();
+ }
+ catch (JsonException)
+ {
+ return new SyntheticCheckSettings();
+ }
+ }
+
+ /// The AOS port when none is given.
+ public const int DefaultAosPort = 2712;
+
+ public const string DefaultQuery = "SELECT 1";
+
+ /// What the check points at, for lists (Danish-neutral).
+ public string Target(string kind) => kind switch
+ {
+ SyntheticCheckKind.Tcp => $"{Host}:{Port?.ToString(CultureInfo.InvariantCulture)}",
+ SyntheticCheckKind.AxLogon => ObjectServer is { Length: > 0 } os ? os : $"{Host}:{(Port ?? DefaultAosPort).ToString(CultureInfo.InvariantCulture)}",
+ SyntheticCheckKind.Http => Url?.ToString() ?? string.Empty,
+ SyntheticCheckKind.Sql => ServerAddress.DataSource(Server ?? string.Empty, Port) + (Database is { Length: > 0 } db ? " / " + db : string.Empty),
+ SyntheticCheckKind.Dns => Host ?? string.Empty,
+ _ => string.Empty,
+ };
+
+ /// The context a SQL login password is sealed to: the server and login of the check (see ).
+ public string SecretContext() => CredentialEnvelope.Context("synthetic-sql:" + ServerAddress.DataSource(Server ?? string.Empty, Port), UserName);
+}
+
+/// How often and how long a check may run.
+public static class SyntheticCheckLimits
+{
+ public const int DefaultIntervalSeconds = 300;
+ public const int MinIntervalSeconds = 60;
+ public const int MaxIntervalSeconds = 86400;
+ public const int DefaultTimeoutSeconds = 15;
+ public const int MaxTimeoutSeconds = 120;
+
+ /// The AX logon starts a separate process and logs on to the AOS: give it longer.
+ public const int DefaultAxTimeoutSeconds = 60;
+}
+
+public static class SyntheticCheckValidation
+{
+ /// Why the check cannot be saved (Danish), or null. : the check is tied to a Windows server.
+ public static string? Validate(string? name, string? kind, SyntheticCheckSettings settings, int intervalSeconds, int timeoutSeconds, bool hasServer)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ if (ServerAddress.ValidateName(name) is { } badName)
+ {
+ return badName;
+ }
+
+ if (!SyntheticCheckKind.IsKnown(kind))
+ {
+ return "Typen skal være " + string.Join(", ", SyntheticCheckKind.All) + ".";
+ }
+
+ if (intervalSeconds is < SyntheticCheckLimits.MinIntervalSeconds or > SyntheticCheckLimits.MaxIntervalSeconds)
+ {
+ return "Intervallet skal være mellem 1 minut og 24 timer.";
+ }
+
+ if (timeoutSeconds is < 1 or > SyntheticCheckLimits.MaxTimeoutSeconds)
+ {
+ return $"Timeout skal være mellem 1 og {SyntheticCheckLimits.MaxTimeoutSeconds} sekunder.";
+ }
+
+ if (timeoutSeconds >= intervalSeconds)
+ {
+ return "Timeout skal være kortere end intervallet.";
+ }
+
+ if (settings.WarnMilliseconds is < 1 or > 600_000)
+ {
+ return "Grænsen for langsomt svar skal være mellem 1 og 600.000 ms.";
+ }
+
+ if (settings.ServiceName is { Length: > 0 } service)
+ {
+ if (!hasServer)
+ {
+ return "Vælg den Windows-server, tjenesten kører på.";
+ }
+
+ if (ServerHost.ValidateServiceNames([service]) is { } badService)
+ {
+ return badService;
+ }
+ }
+
+ return kind switch
+ {
+ SyntheticCheckKind.Tcp => ServerHost.Validate(settings.Host) ?? ValidatePort(settings.Port, required: true),
+ SyntheticCheckKind.AxLogon => ServerHost.Validate(settings.Host) ?? ValidatePort(settings.Port, required: false) ?? ValidateAx(settings),
+ SyntheticCheckKind.Http => ValidateHttp(settings),
+ SyntheticCheckKind.Sql => ValidateSql(settings),
+ SyntheticCheckKind.Dns => ServerHost.Validate(settings.Host) ?? ValidateAddresses(settings.ExpectedAddresses),
+ _ => null,
+ };
+ }
+
+ private static string? ValidatePort(int? port, bool required) =>
+ port is null ? (required ? "Angiv porten." : null) : port is < 1 or > 65535 ? "Porten skal være mellem 1 og 65535." : null;
+
+ private static string? ValidateAx(SyntheticCheckSettings settings)
+ {
+ if (settings.Company is { } company && (company.Length > 10 || company.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')))
+ {
+ return "Firmaet skal være AX' firma-id (DataAreaId), fx DAT.";
+ }
+
+ if (settings.ObjectServer is { Length: > 256 } || settings.ObjectServer?.Any(char.IsControl) == true)
+ {
+ return "Object server er ugyldig (fx 01@aos01:2712).";
+ }
+
+ return settings.Configuration is { Length: > 128 } || settings.Configuration?.Any(char.IsControl) == true ? "Konfigurationens navn er ugyldigt." : null;
+ }
+
+ private static string? ValidateHttp(SyntheticCheckSettings settings)
+ {
+ if (settings.Url is not { IsAbsoluteUri: true } url || (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps))
+ {
+ return "Angiv en adresse, der starter med http:// eller https://.";
+ }
+
+ if (!string.IsNullOrEmpty(url.UserInfo))
+ {
+ return "Adressen må ikke indeholde brugernavn eller adgangskode.";
+ }
+
+ if (settings.ExpectedStatus.Any(s => s is < 100 or > 599) || settings.ExpectedStatus.Count > 20)
+ {
+ return "Forventede statuskoder skal være mellem 100 og 599.";
+ }
+
+ return settings.ExpectedText is { Length: > 500 } ? "Den forventede tekst må højst være 500 tegn." : null;
+ }
+
+ private static string? ValidateSql(SyntheticCheckSettings settings)
+ {
+ if (ServerAddress.Validate(settings.Server, settings.Port) is { } server)
+ {
+ return server;
+ }
+
+ if (settings.Authentication is not ("Windows" or "SqlLogin"))
+ {
+ return "Godkendelse skal være Windows (agentens tjenestekonto) eller SqlLogin.";
+ }
+
+ if (settings.Authentication == "SqlLogin" && (string.IsNullOrWhiteSpace(settings.UserName) || settings.UserName.Length > 128))
+ {
+ return "Angiv SQL-login'et (højst 128 tegn).";
+ }
+
+ if (settings.Encryption is not ("Mandatory" or "Optional"))
+ {
+ return "Kryptering skal være Mandatory eller Optional.";
+ }
+
+ if (settings.Database is { } database && (database.Length > 128 || database.Any(char.IsControl)))
+ {
+ return "Databasenavnet er ugyldigt.";
+ }
+
+ if (settings.Query is { Length: > 0 } query)
+ {
+ if (query.Length > 4000)
+ {
+ return "Forespørgslen må højst være 4000 tegn.";
+ }
+
+ return CustomRuleDefinition.QueryProblems(query).FirstOrDefault();
+ }
+
+ return null;
+ }
+
+ private static string? ValidateAddresses(IReadOnlyList addresses) =>
+ addresses.Count > 20 ? "Højst 20 forventede adresser."
+ : addresses.FirstOrDefault(a => !IPAddress.TryParse(a, out _)) is { } bad ? $"\"{bad}\" er ikke en IP-adresse."
+ : null;
+}
+
+/// One measurement of a run, as shown and stored. Sensitive values stay on-prem and are redacted in the API.
+public sealed record SyntheticCheckDetail(string Name, string Value, bool Sensitive = false);
+
+/// What one run of a synthetic check found.
+public sealed record SyntheticCheckResult
+{
+ public required DateTimeOffset At { get; init; }
+ public required bool Success { get; init; }
+
+ /// How long the check took (the answer's time for HTTP, SQL, TCP).
+ public int DurationMs { get; init; }
+
+ /// What happened (Danish), e.g. "Porten svarede på 12 ms" or the error.
+ public required string Message { get; init; }
+
+ public IReadOnlyList Details { get; init; } = [];
+
+ /// Http over https: when the server's certificate expires.
+ public DateTimeOffset? CertificateExpiresAt { get; init; }
+
+ public string? CertificateSubject { get; init; }
+
+ /// The state of the linked Windows service when the check ran (Running, Stopped …), when there is one and it could be read.
+ public string? ServiceState { get; init; }
+
+ /// Only part of the check could be done (AX: the port, because the Business Connector is not installed).
+ public bool Partial { get; init; }
+}
diff --git a/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs b/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs
new file mode 100644
index 0000000..c717909
--- /dev/null
+++ b/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs
@@ -0,0 +1,72 @@
+using SQLInsight.Core.SyntheticChecks;
+
+namespace SQLInsight.Core.Tests;
+
+public class SyntheticCheckTests
+{
+ private static string? Validate(string kind, SyntheticCheckSettings settings, bool hasServer = false, int interval = 300, int timeout = 15) =>
+ SyntheticCheckValidation.Validate("tjek", kind, settings, interval, timeout, hasServer);
+
+ [Fact]
+ public void Valid_checks_of_each_kind()
+ {
+ Assert.Null(Validate(SyntheticCheckKind.Tcp, new() { Host = "aos01", Port = 2712 }));
+ Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/sites/DynamicsAx"), ExpectedStatus = [200, 401], ExpectedText = "Dynamics" }));
+ Assert.Null(Validate(SyntheticCheckKind.Sql, new() { Server = @"sql01\AX", Database = "AX2012_PROD", Query = "SELECT COUNT(*) FROM dbo.DATAAREA" }));
+ Assert.Null(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Authentication = "SqlLogin", UserName = "axapp" }));
+ Assert.Null(Validate(SyntheticCheckKind.Dns, new() { Host = "aos01.firma.local", ExpectedAddresses = ["10.0.0.5"] }));
+ Assert.Null(Validate(SyntheticCheckKind.AxLogon, new() { Host = "aos01", Company = "DAT", ServiceName = "AOS60$01" }, hasServer: true));
+ }
+
+ [Theory]
+ [InlineData(SyntheticCheckKind.Tcp, "Angiv porten")]
+ [InlineData(SyntheticCheckKind.Http, "http://")]
+ [InlineData(SyntheticCheckKind.Sql, "Angiv serveren")]
+ [InlineData(SyntheticCheckKind.Dns, "Angiv serverens navn")]
+ [InlineData(SyntheticCheckKind.AxLogon, "Angiv serverens navn")]
+ public void Missing_targets(string kind, string error) =>
+ Assert.Contains(error, Validate(kind, kind == SyntheticCheckKind.Tcp ? new() { Host = "aos01" } : new()), StringComparison.Ordinal);
+
+ [Fact]
+ public void A_sql_check_only_reads()
+ {
+ Assert.Contains("SELECT", Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "UPDATE dbo.CustTable SET Name = 'x'" }), StringComparison.Ordinal);
+ Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "SELECT 1; DROP TABLE dbo.x" }));
+ Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "SELECT * INTO dbo.Copy FROM dbo.CustTable" }));
+ Assert.NotNull(Validate(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "EXEC sp_who" }));
+ }
+
+ [Fact]
+ public void Limits_and_service_needs_a_server()
+ {
+ Assert.Contains("1 minut", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, interval: 30), StringComparison.Ordinal);
+ Assert.Contains("kortere end intervallet", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, interval: 60, timeout: 60), StringComparison.Ordinal);
+ Assert.Contains("Windows-server", Validate(SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1, ServiceName = "W3SVC" }), StringComparison.Ordinal);
+ Assert.NotNull(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://bruger:kode@ep/") }));
+ Assert.NotNull(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("ftp://ep/") }));
+ Assert.NotNull(Validate(SyntheticCheckKind.AxLogon, new() { Host = "aos01", Company = "DAT; DROP" }));
+ Assert.NotNull(SyntheticCheckValidation.Validate("tjek", "Ping", new(), 300, 15, false));
+ Assert.NotNull(SyntheticCheckValidation.Validate(" ", SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, 300, 15, false));
+ }
+
+ [Fact]
+ public void Settings_round_trip_as_json_and_bad_json_is_empty_settings()
+ {
+ var settings = new SyntheticCheckSettings { Url = new Uri("https://ep/"), ExpectedStatus = [200], UseWindowsAuthentication = true, ServiceName = "W3SVC" };
+
+ Assert.Equal(settings.ToJson(), SyntheticCheckSettings.FromJson(settings.ToJson()).ToJson());
+ Assert.Null(SyntheticCheckSettings.FromJson("{not json").Url);
+ Assert.Equal("aos01:2712", new SyntheticCheckSettings { Host = "aos01" }.Target(SyntheticCheckKind.AxLogon));
+ Assert.Equal(@"sql01\AX / AXDB", new SyntheticCheckSettings { Server = @"sql01\AX", Database = "AXDB" }.Target(SyntheticCheckKind.Sql));
+ }
+
+ [Fact]
+ public void A_sql_password_is_bound_to_the_server_and_login()
+ {
+ var a = new SyntheticCheckSettings { Server = "sql01", UserName = "axapp" }.SecretContext();
+
+ Assert.NotEqual(a, new SyntheticCheckSettings { Server = "sql02", UserName = "axapp" }.SecretContext());
+ Assert.NotEqual(a, new SyntheticCheckSettings { Server = "sql01", UserName = "sa" }.SecretContext());
+ Assert.NotEqual(a, Configuration.CredentialEnvelope.Context("sql01", "axapp"));
+ }
+}
diff --git a/tests/SQLInsight.Core.Tests/WindowsServerTests.cs b/tests/SQLInsight.Core.Tests/WindowsServerTests.cs
new file mode 100644
index 0000000..bb9d33e
--- /dev/null
+++ b/tests/SQLInsight.Core.Tests/WindowsServerTests.cs
@@ -0,0 +1,331 @@
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Core.Tests;
+
+public class DiskForecastTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
+
+ /// Hourly samples for days back, free space from a function of the age in days.
+ private static List History(int days, Func free, double size = 100) =>
+ Enumerable.Range(0, (days * 24) + 1).Select(h => Now.AddHours(-h)).Select(at => new DiskHistoryPoint(at, size, free((Now - at).TotalDays))).Reverse().ToList();
+
+ [Fact]
+ public void Steady_growth_gives_days_until_full()
+ {
+ // 1 GB per day used, 20 GB free now: full in 20 days.
+ var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now);
+
+ Assert.NotNull(forecast.DaysUntilFull);
+ Assert.Equal(20, forecast.DaysUntilFull!.Value, 1);
+ Assert.Equal(1, forecast.GrowthGbPerDay, 3);
+ Assert.Equal(1, forecast.Fit, 3);
+ Assert.Equal("fuld om ca. 20 dage", forecast.Describe());
+ Assert.Equal(Now.AddDays(20).Date, forecast.FullAt(Now)!.Value.Date);
+ }
+
+ [Fact]
+ public void Noise_around_a_trend_still_forecasts()
+ {
+ var random = new Random(7);
+ var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 30 + (2 * age) + (random.NextDouble() - 0.5)), Now);
+
+ Assert.InRange(forecast.DaysUntilFull!.Value, 14, 16);
+ }
+
+ [Fact]
+ public void A_flat_or_shrinking_volume_has_no_forecast()
+ {
+ Assert.Null(DiskForecaster.Forecast(@"C:\", History(14, _ => 40), Now).DaysUntilFull);
+ var emptying = DiskForecaster.Forecast(@"C:\", History(14, age => 40 - age), Now);
+ Assert.Null(emptying.DaysUntilFull);
+ Assert.Equal("fyldes ikke op", emptying.Note);
+ }
+
+ [Fact]
+ public void A_sawtooth_of_backups_written_and_deleted_is_not_a_trend()
+ {
+ // Fills 10 GB every night and is cleaned up every morning.
+ var forecast = DiskForecaster.Forecast(@"E:\", History(14, age => 50 - ((age % 1) * 10)), Now);
+
+ Assert.Null(forecast.DaysUntilFull);
+ }
+
+ [Fact]
+ public void Too_little_history_says_so()
+ {
+ var forecast = DiskForecaster.Forecast(@"D:\", History(1, age => 20 + (5 * age)), Now);
+
+ Assert.Null(forecast.DaysUntilFull);
+ Assert.Contains("for lidt historik", forecast.Note, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void An_extended_volume_starts_a_new_trend()
+ {
+ // Was 100 GB and filling fast until 5 days ago; extended to 200 GB and now flat.
+ var history = History(14, age => age > 5 ? 5 + age : 100).Select(p => (Now - p.At).TotalDays > 5 ? p : p with { SizeGb = 200 }).ToList();
+
+ var forecast = DiskForecaster.Forecast(@"D:\", history, Now);
+
+ Assert.Null(forecast.DaysUntilFull);
+ Assert.InRange(forecast.HistoryDays, 4.9, 5.1);
+ }
+
+ [Fact]
+ public void A_full_volume_is_full_now_and_far_futures_are_not_forecasts()
+ {
+ Assert.Equal(0, DiskForecaster.Forecast(@"D:\", History(14, age => Math.Max(0, age - 1)), Now).DaysUntilFull);
+ var slow = DiskForecaster.Forecast(@"D:\", History(14, age => 500 + (0.5 * age)), Now, new DiskForecastSettings());
+ Assert.Null(slow.DaysUntilFull);
+ Assert.Equal("fuld om mere end et år", slow.Note);
+ }
+
+ [Fact]
+ public void Samples_older_than_the_history_window_are_ignored()
+ {
+ // Fast growth 20–40 days ago, flat for the last 14.
+ var history = Enumerable.Range(0, 40 * 24).Select(h => Now.AddHours(-h))
+ .Select(at => new DiskHistoryPoint(at, 100, (Now - at).TotalDays > 20 ? (Now - at).TotalDays : 30)).Reverse().ToList();
+
+ Assert.Null(DiskForecaster.Forecast(@"D:\", history, Now).DaysUntilFull);
+ }
+}
+
+public class ServerPerformanceTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void Cpu_is_one_minus_the_idle_share_of_the_interval()
+ {
+ var first = new ServerPerformanceRaw { CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000 };
+ // One minute = 600,000,000 × 100 ns; idle 150,000,000 of it → 75 % busy.
+ var second = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000 };
+
+ Assert.Equal(75, ServerPerformanceMath.Cpu(first, second)!.Value, 6);
+ Assert.Null(ServerPerformanceMath.Cpu(null, second));
+ Assert.Null(ServerPerformanceMath.Cpu(second, first)); // counters went backwards: rebooted
+ }
+
+ [Fact]
+ public void Pages_per_second_uses_the_performance_frequency()
+ {
+ var first = new ServerPerformanceRaw { CapturedAt = T0, Pages = 1000, MemoryPerfTime = 10_000_000, MemoryPerfFrequency = 10_000_000 };
+ var second = first with { CapturedAt = T0.AddMinutes(1), Pages = 61_000, MemoryPerfTime = 610_000_000 };
+
+ Assert.Equal(1000, ServerPerformanceMath.PagesPerSecond(first, second)!.Value, 6);
+ }
+
+ [Fact]
+ public void Sample_has_available_percent_and_commit()
+ {
+ var raw = new ServerPerformanceRaw { CapturedAt = T0, AvailableMb = 1024, CommittedBytes = 9, CommitLimit = 10 };
+ var sample = ServerPerformanceMath.Sample(null, raw, new ServerOsInfo { TotalMemoryMb = 8192 });
+
+ Assert.Equal(12.5, sample.AvailablePercent!.Value, 6);
+ Assert.Equal(90, sample.CommitPercent!.Value, 6);
+ Assert.Null(sample.CpuPercent);
+ }
+
+ [Fact]
+ public void Sustained_needs_most_of_the_period_covered()
+ {
+ var samples = Enumerable.Range(0, 15).Select(m => new ServerPerformanceSample(T0.AddMinutes(-m), 95, null, null, null, null)).Reverse().ToList();
+
+ Assert.Equal(95, ServerPerformanceMath.SustainedAverage(samples, s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1)));
+ Assert.Null(ServerPerformanceMath.SustainedAverage(samples.TakeLast(5).ToList(), s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1)));
+ }
+
+ [Fact]
+ public void The_window_keeps_an_hour()
+ {
+ IReadOnlyList window = [];
+ for (var m = 0; m < 90; m++)
+ {
+ window = ServerPerformanceMath.Append(window, new ServerPerformanceSample(T0.AddMinutes(m), 10, null, null, null, null));
+ }
+
+ Assert.Equal(60, window.Count);
+ Assert.Equal(T0.AddMinutes(89), window[^1].At);
+ }
+}
+
+public class ServerEventTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
+
+ private static ServerEventEntry Entry(string source, long id, int level = 2, string log = "System", int minutesAgo = 1, string? message = "tekst") =>
+ new(log, source, id, level, Now.AddMinutes(-minutesAgo), message);
+
+ [Theory]
+ [InlineData("Microsoft-Windows-Kernel-Power", 41, 1, ServerEventCategory.UnexpectedShutdown)]
+ [InlineData("EventLog", 6008, 2, ServerEventCategory.UnexpectedShutdown)]
+ [InlineData("disk", 153, 3, ServerEventCategory.Disk)]
+ [InlineData("storvsc", 129, 3, ServerEventCategory.Disk)]
+ [InlineData("Ntfs", 55, 2, ServerEventCategory.Disk)]
+ [InlineData("Srv", 2019, 2, ServerEventCategory.Resources)]
+ [InlineData("Service Control Manager", 7031, 2, ServerEventCategory.ServiceFailure)]
+ [InlineData("Application Error", 1000, 2, ServerEventCategory.ApplicationCrash)]
+ [InlineData("Microsoft Dynamics AX Object Server 6.0$01", 110, 2, ServerEventCategory.DynamicsAx)]
+ [InlineData("Some-Driver", 5, 1, ServerEventCategory.Critical)]
+ [InlineData("Some-App", 5, 2, ServerEventCategory.Error)]
+ public void Categorize(string source, long id, int level, ServerEventCategory expected) =>
+ Assert.Equal(expected, ServerEventAnalysis.Categorize(Entry(source, id, level)));
+
+ [Fact]
+ public void Repeated_events_are_one_group_with_the_newest_sample()
+ {
+ var (groups, ignored) = ServerEventAnalysis.Group(
+ [
+ Entry("Service Control Manager", 7031, minutesAgo: 30, message: "AOS stoppede (1)"),
+ Entry("Service Control Manager", 7031, minutesAgo: 5, message: "AOS stoppede (2)"),
+ Entry("service control manager", 7031, minutesAgo: 10, message: null),
+ Entry("Some-App", 9, log: "Application"),
+ Entry("Some-App", 9, level: 3, log: "Application"), // a warning: not collected
+ ], ServerEventAnalysis.DefaultIgnore);
+
+ Assert.Equal(0, ignored);
+ Assert.Equal(2, groups.Count);
+ var scm = groups[0];
+ Assert.Equal(ServerEventCategory.ServiceFailure, scm.Category);
+ Assert.Equal(3, scm.Count);
+ Assert.Equal("AOS stoppede (2)", scm.Sample);
+ Assert.Equal(Now.AddMinutes(-30), scm.FirstAt);
+ Assert.Equal(Now.AddMinutes(-5), scm.LastAt);
+ }
+
+ [Fact]
+ public void Known_noise_and_sql_server_sources_are_left_out()
+ {
+ var (groups, ignored) = ServerEventAnalysis.Group(
+ [
+ Entry("Microsoft-Windows-DistributedCOM", 10016),
+ Entry("Schannel", 36887),
+ Entry("MSSQL$PROD", 17053, log: "Application"),
+ Entry("MSSQLSERVER", 823, log: "Application"),
+ Entry("Schannel", 36871),
+ ], ServerEventAnalysis.DefaultIgnore);
+
+ Assert.Equal(4, ignored);
+ Assert.Equal(36871, Assert.Single(groups).EventId);
+ }
+
+ [Theory]
+ [InlineData("Some-App:9", true)]
+ [InlineData("Some-App", true)]
+ [InlineData("Some*", true)]
+ [InlineData("Some*:10", false)]
+ [InlineData("Some-App:*", true)]
+ [InlineData("Other:9", false)]
+ public void Ignore_patterns(string pattern, bool ignored) =>
+ Assert.Equal(ignored, ServerEventAnalysis.IsIgnored(Entry("Some-App", 9), [pattern]));
+
+ [Fact]
+ public void Recent_merges_groups_of_the_last_day_and_drops_older()
+ {
+ var (earlier, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 60 * 20), Entry("Some-App", 9, minutesAgo: 60 * 30)], []);
+ var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []);
+
+ var recent = ServerEventAnalysis.Recent(earlier, current, Now);
+
+ var disk = Assert.Single(recent);
+ Assert.Equal(2, disk.Count);
+ Assert.Equal(Now.AddHours(-20), disk.FirstAt);
+ Assert.Equal(Now.AddMinutes(-2), disk.LastAt);
+ }
+
+ [Fact]
+ public void Samples_are_cut()
+ {
+ var (groups, _) = ServerEventAnalysis.Group([Entry("Some-App", 9, message: new string('x', 2000))], []);
+
+ Assert.Equal(500, groups[0].Sample!.Length);
+ }
+}
+
+public class ServerConfigurationTests
+{
+ [Theory]
+ [InlineData("aos01", null)]
+ [InlineData("aos01.firma.local", null)]
+ [InlineData("10.0.0.5", null)]
+ [InlineData("fe80::1", null)]
+ [InlineData(@"sql01\PROD", "uden instansnavn")]
+ [InlineData("aos01:2712", "uden instansnavn")]
+ [InlineData("", "Angiv")]
+ public void Hosts(string host, string? error)
+ {
+ var result = ServerHost.Validate(host);
+ if (error is null)
+ {
+ Assert.Null(result);
+ }
+ else
+ {
+ Assert.Contains(error, result, StringComparison.Ordinal);
+ }
+ }
+
+ [Theory]
+ [InlineData(@"tcp:SQL01.firma.local\PROD,1433", "sql01.firma.local", false)]
+ [InlineData("sql02,1500", "sql02", false)]
+ [InlineData(@".\SQLEXPRESS", "agent01", false)]
+ [InlineData("(local)", "agent01", false)]
+ [InlineData("localhost", "agent01", false)]
+ [InlineData("(localdb)\\MSSQLLocalDB", null, true)]
+ [InlineData("kunde.database.windows.net", null, true)]
+ public void The_computer_behind_a_sql_data_source(string dataSource, string? host, bool skipped)
+ {
+ var (found, reason) = ServerHost.FromDataSource(dataSource, "AGENT01");
+
+ Assert.Equal(host, found);
+ Assert.Equal(skipped, reason is not null);
+ }
+
+ [Fact]
+ public void Lists_of_service_names()
+ {
+ Assert.Equal(["AOS60$01", "W3SVC"], ServerHost.ParseList("AOS60$01, W3SVC\nw3svc;"));
+ Assert.Null(ServerHost.ValidateServiceNames(["AOS60$01", "MSSQL$PROD"]));
+ Assert.NotNull(ServerHost.ValidateServiceNames([@"bad\name"]));
+ }
+
+ [Theory]
+ [InlineData(@"NT SERVICE\SQLInsight Agent", "aos01.firma.local", @"FIRMA\AGENT01$")]
+ [InlineData("LocalSystem", "aos01", @"FIRMA\AGENT01$")]
+ [InlineData(@"NT SERVICE\SQLInsight Agent", "agent01.firma.local", @"NT SERVICE\SQLInsight Agent")]
+ [InlineData(@"FIRMA\gmsa-sqlinsight$", "aos01", @"FIRMA\gmsa-sqlinsight$")]
+ public void The_account_the_agent_arrives_as(string service, string host, string expected) =>
+ Assert.Equal(expected, ServerGrantScript.AccountFor(service, host, "AGENT01", "FIRMA"));
+
+ [Fact]
+ public void Grant_script_is_filled_in_and_never_makes_the_agent_administrator()
+ {
+ var script = ServerGrantScript.For(@"FIRMA\AGENT01$");
+
+ Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$',", script, StringComparison.Ordinal);
+ Assert.DoesNotContain("DOMÆNE\\AGENTSERVER$', #", script, StringComparison.Ordinal);
+ Assert.Contains("S-1-5-32-558", script, StringComparison.Ordinal); // Performance Monitor Users
+ Assert.Contains("S-1-5-32-573", script, StringComparison.Ordinal); // Event Log Readers
+ Assert.DoesNotContain("S-1-5-32-544", script, StringComparison.Ordinal); // Administrators
+ Assert.Contains("0x21", script, StringComparison.Ordinal);
+ Assert.Contains("CCLCRPRC", script, StringComparison.Ordinal);
+ Assert.Equal(script, ServerGrantScript.For(@"FIRMA\AGENT01$"));
+ Assert.Contains("'O''Brien'", ServerGrantScript.For("O'Brien"), StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(0u, false)]
+ [InlineData(TaskResults.Running, false)]
+ [InlineData(TaskResults.HasNotRun, false)]
+ [InlineData(TaskResults.AlreadyRunning, false)]
+ [InlineData(1u, true)]
+ [InlineData(0x80070005u, true)]
+ [InlineData(TaskResults.TerminatedByUser, true)]
+ public void Task_results(uint result, bool failure)
+ {
+ Assert.Equal(failure, TaskResults.IsFailure(result));
+ Assert.False(string.IsNullOrWhiteSpace(TaskResults.Describe(result)));
+ }
+}