Windows servers and synthetic checks core: data, disk forecast, event dedup, grant script

- Core/Servers: what the agent collects from a Windows server (CPU and memory from
  raw counters, disks, services, events, scheduled tasks), roles and sources, host
  validation and the computer behind a SQL Server data source.
- DiskForecaster: "fuld om ca. N dage" from a least-squares line over 14 days of
  SQLInsight's own samples, only since the volume last changed size, with sanity
  bounds (history, samples, R², max one year).
- ServerEventAnalysis: noise filter, grouping and a 24-hour window for System and
  Application events, like the SQL Server error log in 1.2.
- db/permissions/grant-windows-server.ps1: least-privilege rights for the agent's
  account (groups, WMI namespaces, SCM read), never local administrator; filled in
  by ServerGrantScript.
- Core/SyntheticChecks: kinds, settings, validation (read-only SQL), results.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 12:49:07 +02:00
parent 90d37be8a5
commit ae20954a91
13 changed files with 1834 additions and 0 deletions

View file

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

View file

@ -7,5 +7,7 @@
<EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" /> <EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" />
<!-- The optional Dynamics AX read access, shown under Dynamics AX → AX-databaser (DynamicsAx/AxReadAccess.cs). --> <!-- The optional Dynamics AX read access, shown under Dynamics AX → AX-databaser (DynamicsAx/AxReadAccess.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-dynamics-ax-read.sql" LogicalName="SQLInsight.Core.grant-dynamics-ax-read.sql" /> <EmbeddedResource Include="..\..\db\permissions\grant-dynamics-ax-read.sql" LogicalName="SQLInsight.Core.grant-dynamics-ax-read.sql" />
<!-- Shown in SQLInsight Web for a Windows server (Servers/ServerGrantScript.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-windows-server.ps1" LogicalName="SQLInsight.Core.grant-windows-server.ps1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,135 @@
using System.Globalization;
namespace SQLInsight.Core.Servers;
/// <summary>A volume's free space at one time, from SQLInsight's own samples.</summary>
public sealed record DiskHistoryPoint(DateTimeOffset At, double SizeGb, double FreeGb);
/// <summary>When a volume will be full if it keeps filling at the rate of the last weeks.</summary>
/// <param name="DaysUntilFull">Null when the volume is not filling up (or the trend is too unclear to say).</param>
/// <param name="GrowthGbPerDay">How fast the used space grows (positive: filling up).</param>
/// <param name="Fit">R² of the trend line: 1 is a straight line, near 0 no trend.</param>
/// <param name="Note">Why there is no forecast (Danish), when there is none.</param>
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;
/// <summary>"fuld om ca. 12 dage" (Danish), or the note.</summary>
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
{
/// <summary>How far back the trend is computed.</summary>
public int HistoryDays { get; init; } = 14;
/// <summary>Less history than this gives no forecast: a few hours say nothing about weeks.</summary>
public double MinimumHistoryDays { get; init; } = 2;
public int MinimumSamples { get; init; } = 12;
/// <summary>R² below this: the free space jumps up and down (backups written and deleted …) and a straight line says nothing.</summary>
public double MinimumFit { get; init; } = 0.5;
/// <summary>A forecast further away than this is not one.</summary>
public double MaximumDays { get; init; } = 365;
/// <summary>Growth slower than this (GB per day) is noise.</summary>
public double MinimumGrowthGbPerDay { get; init; } = 0.01;
}
/// <summary>
/// "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.
/// </summary>
public static class DiskForecaster
{
public static DiskForecast Forecast(string volume, IReadOnlyList<DiskHistoryPoint> 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);
}
/// <summary>Slope (GB per day) and R² of free space over time.</summary>
public static (double Slope, double Fit) Regression(IReadOnlyList<DiskHistoryPoint> 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);
}
}

View file

@ -0,0 +1,162 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Servers;
/// <summary>What a Windows server is used for: shown in the UI and used to pick sensible defaults (e.g. the AOS service).</summary>
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<string> 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",
};
}
/// <summary>Where a Windows server in insight.WindowsServers comes from.</summary>
public static class ServerSources
{
/// <summary>Added in SQLInsight Web or API v1.</summary>
public const string Web = "Web";
/// <summary>The computer behind a monitored SQL Server instance: added and removed by the agent.</summary>
public const string SqlHost = "SqlHost";
}
/// <summary>How a Windows server is addressed: a computer name, a DNS name or an IP address. No instance, no port.</summary>
public static partial class ServerHost
{
public const int MaxLength = 255;
/// <summary>Why <paramref name="host"/> cannot be used (Danish), or null when it can.</summary>
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.";
}
/// <summary>The form hosts are compared and stored in: trimmed, lower case.</summary>
public static string Normalize(string host)
{
ArgumentNullException.ThrowIfNull(host);
return host.Trim().TrimEnd('.').ToLowerInvariant();
}
/// <summary>The short computer name of a host (aos01.firma.local → AOS01); an IP address stays as it is.</summary>
public static string ShortName(string host)
{
ArgumentNullException.ThrowIfNull(host);
var trimmed = host.Trim();
return IPAddress.TryParse(trimmed, out _) ? trimmed : trimmed.Split('.')[0].ToUpperInvariant();
}
/// <summary>
/// The computer behind a SQL Server data source (<c>tcp:sql01.firma.local\PROD,1433</c> → <c>sql01.firma.local</c>),
/// or why it is not a Windows server the agent can look at (Danish). Aliases for the local computer become
/// <paramref name="localMachineName"/>.
/// </summary>
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);
}
/// <summary>Whether <paramref name="host"/> is the agent's own computer (then collection needs no network).</summary>
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);
}
/// <summary>A comma- or line-separated list of service or task names, trimmed and without duplicates.</summary>
public static IReadOnlyList<string> ParseList(string? text) =>
string.IsNullOrWhiteSpace(text)
? []
: text.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>Why a list of service names cannot be used (Danish), or null.</summary>
public static string? ValidateServiceNames(IEnumerable<string> 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();
}

View file

@ -0,0 +1,233 @@
namespace SQLInsight.Core.Servers;
/// <summary>What kind of Windows event this is, for the rules. Ordered by how much it matters (most last).</summary>
public enum ServerEventCategory
{
/// <summary>An error with nothing special about it: only reported when it repeats.</summary>
Error,
/// <summary>An application or the AOS crashed or hung (Application Error 1000, .NET Runtime 1026 …).</summary>
ApplicationCrash,
/// <summary>Errors from Dynamics AX's Application Object Server.</summary>
DynamicsAx,
/// <summary>A service stopped unexpectedly or could not start (Service Control Manager 7031, 7034, 7000 …).</summary>
ServiceFailure,
/// <summary>Level Critical with no more specific category.</summary>
Critical,
/// <summary>Out of memory or another resource (srv 2019/2020, Resource-Exhaustion-Detector 2004).</summary>
Resources,
/// <summary>Disk, controller or file system errors (disk 7/11/51/153, storport 129, Ntfs 55/98).</summary>
Disk,
/// <summary>The server went down without being shut down (Kernel-Power 41, EventLog 6008, a blue screen).</summary>
UnexpectedShutdown,
}
/// <summary>An event from the System or Application log. Level: 1 critical, 2 error.</summary>
/// <param name="Message">Can name users, computers and paths: sensitive.</param>
public sealed record ServerEventEntry(string Log, string Source, long EventId, int Level, DateTimeOffset At, string? Message = null, long? RecordId = null);
/// <summary>The same event repeated, summed: one line per log, source and event id.</summary>
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; }
/// <summary>The newest event read; the next collection continues after it.</summary>
public required DateTimeOffset ReadUntil { get; init; }
/// <summary>How the log was read: "Event Log (RPC)" or "CIM (Win32_NTLogEvent)".</summary>
public string? Method { get; init; }
/// <summary>What was found since the previous collection, grouped (noise left out).</summary>
public IReadOnlyList<ServerEventGroup> Groups { get; init; } = [];
/// <summary>The groups of the last <see cref="ServerEventAnalysis.RecentWindow"/>: they keep findings open.</summary>
public IReadOnlyList<ServerEventGroup> Recent { get; init; } = [];
/// <summary>Events left out as known noise in this collection.</summary>
public int Ignored { get; init; }
/// <summary>Why less is seen than the logs have (Danish), e.g. the collection was capped.</summary>
public string? Coverage { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public static class ServerEventAnalysis
{
public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
/// <summary>The logs that are read.</summary>
public static readonly IReadOnlyList<string> Logs = ["System", "Application"];
/// <summary>
/// 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*".
/// </summary>
public static readonly IReadOnlyList<string> 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,
};
}
/// <summary>Whether <paramref name="entry"/> matches an ignore entry ("Kilde:id", "Kilde", "Kilde*", "Kilde*:id").</summary>
public static bool IsIgnored(ServerEventEntry entry, IEnumerable<string> 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;
}
/// <summary>Sums repeated events: one group per log, source and event id, with the newest message as a sample. Noise is counted, not grouped.</summary>
public static (IReadOnlyList<ServerEventGroup> Groups, int Ignored) Group(IEnumerable<ServerEventEntry> entries, IEnumerable<string> ignore)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(ignore);
var ignoreList = ignore.ToList();
var ignored = 0;
var kept = new List<ServerEventEntry>();
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);
}
/// <summary>The groups of the last day: this collection's plus the earlier ones still in the window, merged.</summary>
public static IReadOnlyList<ServerEventGroup> Recent(IReadOnlyList<ServerEventGroup> previous, IReadOnlyList<ServerEventGroup> 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;
}
}

View file

@ -0,0 +1,51 @@
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Servers;
/// <summary>
/// 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.
/// </summary>
public static partial class ServerGrantScript
{
/// <summary>The script with <paramref name="account"/> as the default for -Account.</summary>
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);
}
/// <summary>The script as shipped, with the placeholder account.</summary>
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();
}
/// <summary>
/// The account the agent arrives as on <paramref name="host"/>: 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.
/// </summary>
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();
}

View file

@ -0,0 +1,76 @@
namespace SQLInsight.Core.Servers;
/// <summary>Rates from two raw counter samples, as Performance Monitor computes them.</summary>
public static class ServerPerformanceMath
{
/// <summary>How long the per-minute samples are kept in memory for the rules.</summary>
public static readonly TimeSpan Window = TimeSpan.FromHours(1);
/// <summary>The interval's sample. CPU and pages/s need the previous raw sample; without it (or after a reboot) they are null.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>PERF_COUNTER_COUNTER: Δcount / (Δtime / frequency).</summary>
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);
}
/// <summary>The window's samples after adding <paramref name="sample"/>; older ones fall out.</summary>
public static IReadOnlyList<ServerPerformanceSample> Append(IReadOnlyList<ServerPerformanceSample> 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();
}
/// <summary>
/// The average over the last <paramref name="period"/>, when the samples cover at least 80 % of it (so a short
/// burst after a gap is not "sustained"). Null otherwise.
/// </summary>
public static double? SustainedAverage(IReadOnlyList<ServerPerformanceSample> samples, Func<ServerPerformanceSample, double?> 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<double>().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;
}
}

View file

@ -0,0 +1,51 @@
namespace SQLInsight.Core.Servers;
/// <summary>
/// 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.
/// </summary>
public static class ServerRequestKind
{
/// <summary>Connect to a Windows server and check which areas the agent may read.</summary>
public const string ServerTest = "ServerTest";
/// <summary>Run a synthetic check now and store the result.</summary>
public const string SyntheticCheckRun = "SyntheticCheckRun";
public static readonly IReadOnlyList<string> All = [ServerTest, SyntheticCheckRun];
public static bool Handles(string kind) => All.Contains(kind, StringComparer.Ordinal);
}
/// <summary>Whether the agent can read one area of a Windows server, and if not, exactly why (Danish).</summary>
/// <param name="Right">What gives the agent the area (from the grant script).</param>
public sealed record ServerAreaStatus(string Area, bool Ok, string? Detail, string Right);
/// <summary>The outcome of a Windows server test, as the agent saw it.</summary>
public sealed record WindowsServerTestResult
{
public bool Reachable { get; init; }
/// <summary>"WS-Man", "DCOM" or "lokal".</summary>
public string? Protocol { get; init; }
public string? Error { get; init; }
/// <summary>What to do about <see cref="Error"/> (Danish).</summary>
public string? Hint { get; init; }
/// <summary>The account the agent connected as (the one the grant script is for).</summary>
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<ServerAreaStatus> Areas { get; init; } = [];
/// <summary>Notes about the result (Danish), e.g. why WS-Man was not used.</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
public IEnumerable<ServerAreaStatus> Missing => Areas.Where(a => !a.Ok);
}

View file

@ -0,0 +1,51 @@
using System.Globalization;
namespace SQLInsight.Core.Servers;
/// <summary>Task Scheduler's result codes (LastTaskResult) in Danish, and which of them mean the task failed.</summary>
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;
/// <summary>Whether the last run ended badly. Running, queued, never run and "already running" are not failures.</summary>
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),
};
/// <summary>Whether a task is Microsoft's own (\Microsoft\…), which is not watched unless asked for.</summary>
public static bool IsMicrosoftTask(string path)
{
ArgumentNullException.ThrowIfNull(path);
return path.StartsWith(@"\Microsoft\", StringComparison.OrdinalIgnoreCase);
}
}

View file

@ -0,0 +1,188 @@
namespace SQLInsight.Core.Servers;
/// <summary>
/// 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".
/// </summary>
public static class ServerArea
{
/// <summary>CPU, memory, paging and uptime (Win32_PerfRawData_PerfOS_*, Win32_OperatingSystem).</summary>
public const string Performance = "performance";
/// <summary>Free space per volume (Win32_Volume, or Win32_LogicalDisk).</summary>
public const string Disks = "disks";
/// <summary>Services and their state (Win32_Service).</summary>
public const string Services = "services";
/// <summary>Errors and critical events in the System and Application logs.</summary>
public const string Events = "events";
/// <summary>Scheduled tasks and their last result (root\Microsoft\Windows\TaskScheduler).</summary>
public const string Tasks = "tasks";
public static readonly IReadOnlyList<string> 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,
};
/// <summary>The Windows right that opens the area to the agent (Danish), as in the grant script.</summary>
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,
};
}
/// <summary>The operating system as Win32_OperatingSystem reports it.</summary>
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; }
/// <summary>The computer's own name (CSName).</summary>
public string? ComputerName { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public sealed record ServerPerformanceRaw
{
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>Win32_PerfRawData_PerfOS_Processor(_Total).PercentProcessorTime: idle time in 100 ns (PERF_100NSEC_TIMER_INV).</summary>
public ulong? ProcessorIdle100Ns { get; init; }
/// <summary>The processor counter's Timestamp_Sys100NS.</summary>
public ulong? ProcessorTimestamp100Ns { get; init; }
/// <summary>Win32_PerfRawData_PerfOS_Memory.PagesPersec (PERF_COUNTER_COUNTER).</summary>
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; }
}
/// <summary>One minute (or interval) of a server's CPU and memory.</summary>
/// <param name="CpuPercent">Average CPU over the interval since the previous raw sample; null for the first one.</param>
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; }
/// <summary>The samples of the last hour, oldest first (sustained CPU, low memory, paging).</summary>
public IReadOnlyList<ServerPerformanceSample> Samples { get; init; } = [];
/// <summary>The boot time seen at the previous collection, when it was different: the server has restarted since.</summary>
public DateTimeOffset? PreviousBootUpTime { get; init; }
/// <summary>When the agent last saw the server restart (kept for a day, so the finding stays visible).</summary>
public DateTimeOffset? RestartSeenAt { get; init; }
/// <summary>"WS-Man" or "DCOM" (or "lokal").</summary>
public string? Protocol { get; init; }
}
/// <param name="Volume">C:\, or the mount point path.</param>
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<ServerDisk> Disks { get; init; } = [];
/// <summary>"fuld om ca. N dage" per volume, from SQLInsight's own history of the free space.</summary>
public IReadOnlyList<DiskForecast> 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<ServerService> Services { get; init; } = [];
}
/// <summary>A scheduled task as Task Scheduler reports it.</summary>
public sealed record ServerTask
{
public required string Path { get; init; }
public required string Name { get; init; }
/// <summary>Ready, Running, Disabled, Queued or Unknown.</summary>
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; }
/// <summary>The tasks that are watched (by default the ones not under \Microsoft\).</summary>
public IReadOnlyList<ServerTask> Tasks { get; init; } = [];
/// <summary>Every task the agent could see, Microsoft's own included.</summary>
public int Visible { get; init; }
/// <summary>Why less is seen than there is (Danish), e.g. tasks the agent may not read.</summary>
public string? Coverage { get; init; }
}
/// <summary>A configured Windows server as the rules see it.</summary>
public sealed record ServerIdentity(int ServerId, string Name, string Host, string Role)
{
public IReadOnlyList<string> WatchedServices { get; init; } = [];
public IReadOnlyList<string> IgnoredServices { get; init; } = [];
}

View file

@ -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;
/// <summary>The kinds of synthetic check: what the agent does, the way a user or an application would.</summary>
public static class SyntheticCheckKind
{
/// <summary>A TCP port answers (e.g. the AOS on 2712).</summary>
public const string Tcp = "Tcp";
/// <summary>An HTTP(S) GET returns the expected status (and text); the TLS certificate is checked.</summary>
public const string Http = "Http";
/// <summary>A SQL Server login works and a simple query answers in time.</summary>
public const string Sql = "Sql";
/// <summary>A name resolves (to the expected addresses).</summary>
public const string Dns = "Dns";
/// <summary>Dynamics AX: the AOS port answers, and a logon through the .NET Business Connector works when it is installed.</summary>
public const string AxLogon = "AxLogon";
public static readonly IReadOnlyList<string> 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,
};
}
/// <summary>
/// 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.
/// </summary>
public sealed record SyntheticCheckSettings
{
/// <summary>Tcp, Dns and AxLogon: the computer (for Dns: the name to resolve).</summary>
public string? Host { get; init; }
/// <summary>Tcp and AxLogon (default 2712), Sql (optional).</summary>
public int? Port { get; init; }
/// <summary>Http: http:// or https://.</summary>
public Uri? Url { get; init; }
/// <summary>Http: the status codes that count as success; empty: 200–399.</summary>
public IReadOnlyList<int> ExpectedStatus { get; init; } = [];
/// <summary>Http: text the answer must contain.</summary>
public string? ExpectedText { get; init; }
/// <summary>Http: log on as the agent's account (Windows authentication), e.g. Enterprise Portal or Reporting Services.</summary>
public bool UseWindowsAuthentication { get; init; }
/// <summary>Http: accept a certificate that is not valid (self-signed …); its expiry is still checked.</summary>
public bool AllowInvalidCertificate { get; init; }
/// <summary>Sql: server or server\instance.</summary>
public string? Server { get; init; }
public string? Database { get; init; }
/// <summary>Sql: Windows (the agent's account) or SqlLogin.</summary>
public string Authentication { get; init; } = "Windows";
public string? UserName { get; init; }
/// <summary>Sql: Mandatory or Optional.</summary>
public string Encryption { get; init; } = "Mandatory";
public bool TrustServerCertificate { get; init; }
/// <summary>Sql: one read-only SELECT; default <c>SELECT 1</c>. Runs on SQL Server 2005 and later.</summary>
public string? Query { get; init; }
/// <summary>Dns: addresses the name must resolve to (any of them); empty: any answer.</summary>
public IReadOnlyList<string> ExpectedAddresses { get; init; } = [];
/// <summary>AxLogon: the company to log on to (DataAreaId), e.g. DAT.</summary>
public string? Company { get; init; }
/// <summary>AxLogon: the Business Connector's object server, e.g. 01@aos01:2712; default host:port.</summary>
public string? ObjectServer { get; init; }
/// <summary>AxLogon: the AX client configuration to use; default the Business Connector's own.</summary>
public string? Configuration { get; init; }
/// <summary>AxLogon: try a logon through the .NET Business Connector when it is installed on the agent's computer.</summary>
public bool BusinessConnectorLogon { get; init; } = true;
/// <summary>The Windows service behind what is checked, on the check's server (e.g. AOS60$01, W3SVC).</summary>
public string? ServiceName { get; init; }
/// <summary>An answer slower than this (ms) three times in a row is a finding.</summary>
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<SyntheticCheckSettings>(json, Json) ?? new SyntheticCheckSettings();
}
catch (JsonException)
{
return new SyntheticCheckSettings();
}
}
/// <summary>The AOS port when none is given.</summary>
public const int DefaultAosPort = 2712;
public const string DefaultQuery = "SELECT 1";
/// <summary>What the check points at, for lists (Danish-neutral).</summary>
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,
};
/// <summary>The context a SQL login password is sealed to: the server and login of the check (see <see cref="CredentialEnvelope"/>).</summary>
public string SecretContext() => CredentialEnvelope.Context("synthetic-sql:" + ServerAddress.DataSource(Server ?? string.Empty, Port), UserName);
}
/// <summary>How often and how long a check may run.</summary>
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;
/// <summary>The AX logon starts a separate process and logs on to the AOS: give it longer.</summary>
public const int DefaultAxTimeoutSeconds = 60;
}
public static class SyntheticCheckValidation
{
/// <summary>Why the check cannot be saved (Danish), or null. <paramref name="hasServer"/>: the check is tied to a Windows server.</summary>
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<string> 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;
}
/// <summary>One measurement of a run, as shown and stored. Sensitive values stay on-prem and are redacted in the API.</summary>
public sealed record SyntheticCheckDetail(string Name, string Value, bool Sensitive = false);
/// <summary>What one run of a synthetic check found.</summary>
public sealed record SyntheticCheckResult
{
public required DateTimeOffset At { get; init; }
public required bool Success { get; init; }
/// <summary>How long the check took (the answer's time for HTTP, SQL, TCP).</summary>
public int DurationMs { get; init; }
/// <summary>What happened (Danish), e.g. "Porten svarede på 12 ms" or the error.</summary>
public required string Message { get; init; }
public IReadOnlyList<SyntheticCheckDetail> Details { get; init; } = [];
/// <summary>Http over https: when the server's certificate expires.</summary>
public DateTimeOffset? CertificateExpiresAt { get; init; }
public string? CertificateSubject { get; init; }
/// <summary>The state of the linked Windows service when the check ran (Running, Stopped …), when there is one and it could be read.</summary>
public string? ServiceState { get; init; }
/// <summary>Only part of the check could be done (AX: the port, because the Business Connector is not installed).</summary>
public bool Partial { get; init; }
}

View file

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

View file

@ -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);
/// <summary>Hourly samples for <paramref name="days"/> days back, free space from a function of the age in days.</summary>
private static List<DiskHistoryPoint> History(int days, Func<double, double> 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<ServerPerformanceSample> 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)));
}
}