diff --git a/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs b/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs
new file mode 100644
index 0000000..4a71c9b
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs
@@ -0,0 +1,257 @@
+using System.Text.Json;
+using Dapper;
+using Microsoft.Data.SqlClient;
+using SQLInsight.Core.Findings;
+using SQLInsight.Core.Servers;
+using SQLInsight.Rules.Engine;
+
+namespace SQLInsight.Repository;
+
+/// What a server finding is about: a Windows server or a synthetic check (insight.ServerFindings.Scope).
+public static class FindingScope
+{
+ public const string Server = "Server";
+ public const string Check = "Check";
+}
+
+/// A finding about a Windows server or a synthetic check, as stored.
+public sealed record StoredServerFinding
+{
+ public long FindingId { get; init; }
+ public string Scope { get; init; } = FindingScope.Server;
+ public int ScopeId { get; init; }
+
+ /// The server's or the check's name.
+ public string ScopeName { get; init; } = string.Empty;
+
+ public string RuleId { get; init; } = string.Empty;
+ public string Subject { get; init; } = string.Empty;
+ public Severity Severity { get; init; }
+ public string Title { get; init; } = string.Empty;
+ public string Explanation { get; init; } = string.Empty;
+ public string Recommendation { get; init; } = string.Empty;
+ public IReadOnlyList Evidence { get; init; } = [];
+ public IReadOnlyList References { get; init; } = [];
+ public DateTime FirstSeenAt { get; init; }
+ public DateTime LastSeenAt { get; init; }
+ public DateTime? ResolvedAt { get; init; }
+}
+
+public sealed partial class InsightRepository
+{
+ private sealed record ServerFindingRow(long FindingId, string Scope, int ScopeId, string? ScopeName, string RuleId, string Subject, byte Severity, string Title,
+ string Explanation, string Recommendation, string EvidenceJson, string ReferencesJson, DateTime FirstSeenAt, DateTime LastSeenAt, DateTime? ResolvedAt);
+
+ private const string ServerFindingColumns = """
+ f.FindingId, f.Scope, f.ScopeId, COALESCE(w.Name, c.Name) AS ScopeName, f.RuleId, f.Subject, f.Severity, f.Title, f.Explanation, f.Recommendation,
+ f.EvidenceJson, f.ReferencesJson, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt
+ """;
+
+ private const string ServerFindingFrom = """
+ insight.ServerFindings f
+ LEFT JOIN insight.WindowsServers w ON f.Scope = 'Server' AND w.ServerId = f.ScopeId
+ LEFT JOIN insight.SyntheticChecks c ON f.Scope = 'Check' AND c.CheckId = f.ScopeId
+ """;
+
+ ///
+ /// Stores the rule outcomes of one server or check and opens, updates and resolves its findings, like
+ /// for instances. Returns what changed, for notifications.
+ ///
+ public async Task SaveScopedRuleResultsAsync(string scope, int scopeId, IReadOnlyList results, DateTimeOffset evaluatedAt,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ ArgumentNullException.ThrowIfNull(results);
+ for (var attempt = 1; ; attempt++)
+ {
+ try
+ {
+ return await SaveScopedRuleResultsOnceAsync(scope, scopeId, results, evaluatedAt, cancellationToken).ConfigureAwait(false);
+ }
+ catch (SqlException ex) when (ex.Number == DeadlockVictim && attempt < 3)
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task SaveScopedRuleResultsOnceAsync(string scope, int scopeId, IReadOnlyList results, DateTimeOffset evaluatedAt,
+ CancellationToken cancellationToken)
+ {
+ var now = Utc(evaluatedAt);
+ var opened = new List<(long, Finding)>();
+ var resolved = new List();
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync(
+ "EXEC sp_getapplock @Resource = N'SQLInsight:ServerFindings', @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 60000;",
+ transaction: transaction).ConfigureAwait(false);
+ foreach (var result in results)
+ {
+ await connection.ExecuteAsync("""
+ MERGE insight.ServerRuleStates AS t
+ USING (SELECT @scope AS Scope, @scopeId AS ScopeId, @ruleId AS RuleId) AS s ON t.Scope = s.Scope AND t.ScopeId = s.ScopeId AND t.RuleId = s.RuleId
+ WHEN MATCHED THEN UPDATE SET RuleTitle = @title, Outcome = @outcome, Reason = @reason, EvaluatedAt = @now
+ WHEN NOT MATCHED THEN INSERT (Scope, ScopeId, RuleId, RuleTitle, Outcome, Reason, EvaluatedAt) VALUES (@scope, @scopeId, @ruleId, @title, @outcome, @reason, @now);
+ """, new { scope, scopeId, ruleId = result.RuleId, title = result.RuleTitle, outcome = (byte)result.Outcome, reason = result.Reason, now }, transaction)
+ .ConfigureAwait(false);
+
+ // Not measured this time: whatever was found before stays open (a missing right should not "resolve" a full disk).
+ if (result.Outcome is not (RuleOutcome.Passed or RuleOutcome.Findings or RuleOutcome.NotApplicable))
+ {
+ continue;
+ }
+
+ var current = result.Findings.Select(f => f.Subject ?? string.Empty).ToList();
+ resolved.AddRange(await connection.QueryAsync("""
+ UPDATE insight.ServerFindings SET ResolvedAt = @now
+ OUTPUT inserted.FindingId, inserted.RuleId, inserted.Subject, inserted.Title, inserted.Severity
+ WHERE Scope = @scope AND ScopeId = @scopeId AND RuleId = @ruleId AND ResolvedAt IS NULL
+ AND Subject NOT IN (SELECT value FROM OPENJSON(@current));
+ """, new { scope, scopeId, ruleId = result.RuleId, now, current = JsonSerializer.Serialize(current, Json) }, transaction).ConfigureAwait(false));
+
+ foreach (var finding in result.Findings)
+ {
+ var newId = await connection.QuerySingleOrDefaultAsync("""
+ UPDATE insight.ServerFindings
+ SET Severity = @severity, Title = @title, Explanation = @explanation, Recommendation = @recommendation,
+ EvidenceJson = @evidence, ReferencesJson = @references, LastSeenAt = @now
+ WHERE Scope = @scope AND ScopeId = @scopeId AND RuleId = @ruleId AND Subject = @subject AND ResolvedAt IS NULL;
+
+ IF @@ROWCOUNT = 0
+ INSERT insight.ServerFindings (Scope, ScopeId, RuleId, Subject, Severity, Title, Explanation, Recommendation, EvidenceJson, ReferencesJson, FirstSeenAt, LastSeenAt)
+ OUTPUT inserted.FindingId
+ VALUES (@scope, @scopeId, @ruleId, @subject, @severity, @title, @explanation, @recommendation, @evidence, @references, @now, @now);
+ """, new
+ {
+ scope,
+ scopeId,
+ ruleId = finding.RuleId,
+ subject = finding.Subject ?? string.Empty,
+ severity = (byte)finding.Severity,
+ title = finding.Title,
+ explanation = finding.Explanation,
+ recommendation = finding.Recommendation,
+ evidence = JsonSerializer.Serialize(finding.Evidence, Json),
+ references = JsonSerializer.Serialize(finding.References, Json),
+ now,
+ }, transaction).ConfigureAwait(false);
+ if (newId is { } id)
+ {
+ opened.Add((id, finding));
+ }
+ }
+ }
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ return new FindingChanges(opened, resolved);
+ }
+
+ /// Server and check findings: active, resolved or all; for one server or check, or all of them.
+ public async Task> GetServerFindingsAsync(string? scope = null, int? scopeId = null, FindingStatus status = FindingStatus.Active,
+ int top = 500, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync($"""
+ SELECT TOP (@top) {ServerFindingColumns}
+ FROM {ServerFindingFrom}
+ WHERE (@scope IS NULL OR f.Scope = @scope) AND (@scopeId IS NULL OR f.ScopeId = @scopeId)
+ AND (@status = 2 OR (@status = 0 AND f.ResolvedAt IS NULL) OR (@status = 1 AND f.ResolvedAt IS NOT NULL))
+ ORDER BY CASE WHEN f.ResolvedAt IS NULL THEN 0 ELSE 1 END, f.Severity DESC, f.FirstSeenAt, f.FindingId;
+ """, new { scope, scopeId, status = (int)status, top }).ConfigureAwait(false);
+ return rows.Select(Map).ToList();
+ }
+
+ public async Task GetServerFindingAsync(long id, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var row = await connection.QuerySingleOrDefaultAsync(
+ $"SELECT {ServerFindingColumns} FROM {ServerFindingFrom} WHERE f.FindingId = @id;", new { id }).ConfigureAwait(false);
+ return row is null ? null : Map(row);
+ }
+
+ /// The earlier occurrences of the same finding (same server or check, rule and subject).
+ public async Task> GetServerFindingHistoryAsync(StoredServerFinding finding, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(finding);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync($"""
+ SELECT TOP (20) {ServerFindingColumns} FROM {ServerFindingFrom}
+ WHERE f.Scope = @Scope AND f.ScopeId = @ScopeId AND f.RuleId = @RuleId AND f.Subject = @Subject AND f.FindingId <> @FindingId
+ ORDER BY f.FirstSeenAt DESC;
+ """, new { finding.Scope, finding.ScopeId, finding.RuleId, finding.Subject, finding.FindingId }).ConfigureAwait(false);
+ return rows.Select(Map).ToList();
+ }
+
+ public async Task> GetServerRuleStatesAsync(string scope, int scopeId, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync<(string RuleId, string RuleTitle, byte Outcome, string? Reason, DateTime EvaluatedAt)>(
+ "SELECT RuleId, RuleTitle, Outcome, Reason, EvaluatedAt FROM insight.ServerRuleStates WHERE Scope = @scope AND ScopeId = @scopeId ORDER BY RuleId;",
+ new { scope, scopeId }).ConfigureAwait(false);
+ return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList();
+ }
+
+ /// Active findings per server or check and severity, for overviews.
+ public async Task>> GetServerFindingCountsAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync<(string Scope, int ScopeId, byte Severity, int Count)>(
+ "SELECT Scope, ScopeId, Severity, COUNT(*) FROM insight.ServerFindings WHERE ResolvedAt IS NULL GROUP BY Scope, ScopeId, Severity;").ConfigureAwait(false);
+ return rows.GroupBy(r => (r.Scope, r.ScopeId)).ToDictionary(
+ g => g.Key,
+ g => (IReadOnlyDictionary)g.ToDictionary(r => (Severity)r.Severity, r => r.Count));
+ }
+
+ private static StoredServerFinding Map(ServerFindingRow r) => new()
+ {
+ FindingId = r.FindingId,
+ Scope = r.Scope,
+ ScopeId = r.ScopeId,
+ ScopeName = r.ScopeName ?? string.Empty,
+ RuleId = r.RuleId,
+ Subject = r.Subject,
+ Severity = (Severity)r.Severity,
+ Title = r.Title,
+ Explanation = r.Explanation,
+ Recommendation = r.Recommendation,
+ Evidence = JsonSerializer.Deserialize>(r.EvidenceJson, Json) ?? [],
+ References = JsonSerializer.Deserialize>(r.ReferencesJson, Json) ?? [],
+ FirstSeenAt = r.FirstSeenAt,
+ LastSeenAt = r.LastSeenAt,
+ ResolvedAt = r.ResolvedAt,
+ };
+
+ // ---- Requests about servers and checks ---------------------------------------------------------------------
+
+ /// Queues a server test or a check run for the agent (SQLInsight Web: INSERT only).
+ public async Task AddServerRequestAsync(string kind, int targetId, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ if (!ServerRequestKind.Handles(kind))
+ {
+ throw new ArgumentException($"Ukendt anmodning: {kind}.", nameof(kind));
+ }
+
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteScalarAsync("""
+ INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt)
+ OUTPUT INSERTED.RequestId
+ VALUES (@kind, @targetId, 'Pending', @by, @now);
+ """, new { kind, targetId, by, now = Utc(now) }).ConfigureAwait(false);
+ }
+
+ /// The newest request of a kind about a server or check.
+ public async Task GetLatestServerRequestAsync(string kind, int targetId, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.QuerySingleOrDefaultAsync(
+ "SELECT TOP 1 * FROM insight.AgentRequests WHERE Kind = @kind AND TargetId = @targetId ORDER BY RequestId DESC;", new { kind, targetId }).ConfigureAwait(false);
+ }
+
+ /// The server or check a request is about (insight.AgentRequests.TargetId).
+ public async Task GetRequestTargetAsync(long requestId, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteScalarAsync("SELECT TargetId FROM insight.AgentRequests WHERE RequestId = @requestId;", new { requestId }).ConfigureAwait(false);
+ }
+}
diff --git a/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs b/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs
new file mode 100644
index 0000000..0f98f13
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs
@@ -0,0 +1,245 @@
+using System.Text.Json;
+using Dapper;
+using SQLInsight.Core.SyntheticChecks;
+
+namespace SQLInsight.Repository;
+
+/// A synthetic check (insight.SyntheticChecks).
+public sealed record SyntheticCheckRow
+{
+ public int CheckId { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string Kind { get; init; } = SyntheticCheckKind.Tcp;
+
+ /// The Windows server the check belongs to (and whose service it looks at), if any.
+ public int? ServerId { get; init; }
+
+ public string? ServerName { get; init; }
+ public string SettingsJson { get; init; } = "{}";
+
+ /// The sealed SQL login password. Only the agent can open it; never shown or returned.
+ public byte[]? SecretCipher { get; init; }
+
+ public string? SecretKeyId { get; init; }
+ public int IntervalSeconds { get; init; } = SyntheticCheckLimits.DefaultIntervalSeconds;
+ public int TimeoutSeconds { get; init; } = SyntheticCheckLimits.DefaultTimeoutSeconds;
+ public bool Paused { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public string? CreatedBy { get; init; }
+ public DateTime UpdatedAt { get; init; }
+ public string? UpdatedBy { get; init; }
+ public DateTime? RemovedAt { get; init; }
+
+ /// The agent could not open the stored password (Danish).
+ public string? SecretError { get; init; }
+
+ public DateTime? LastRunAt { get; init; }
+ public bool? LastSuccess { get; init; }
+ public int? LastDurationMs { get; init; }
+ public string? LastMessage { get; init; }
+ public int ConsecutiveFailures { get; init; }
+
+ public SyntheticCheckSettings Settings => SyntheticCheckSettings.FromJson(SettingsJson);
+
+ public bool HasSecret => SecretCipher is { Length: > 0 };
+}
+
+/// A new or changed check from SQLInsight Web. A null on a change keeps the stored password.
+public sealed record SyntheticCheckInput(
+ string Name,
+ string Kind,
+ int? ServerId,
+ SyntheticCheckSettings Settings,
+ byte[]? SecretCipher,
+ string? SecretKeyId,
+ int IntervalSeconds,
+ int TimeoutSeconds,
+ bool ClearSecret = false,
+ bool Paused = false);
+
+/// One stored run of a check.
+public sealed record StoredSyntheticCheckResult
+{
+ public long ResultId { get; init; }
+ public int CheckId { get; init; }
+ public DateTime RunAt { get; init; }
+ public bool Success { get; init; }
+ public int DurationMs { get; init; }
+ public string Message { get; init; } = string.Empty;
+ public string? DetailsJson { get; init; }
+ public DateTime? CertificateExpiresAt { get; init; }
+ public string? CertificateSubject { get; init; }
+ public string? ServiceState { get; init; }
+ public bool Partial { get; init; }
+
+ public IReadOnlyList Details
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(DetailsJson))
+ {
+ return [];
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize>(DetailsJson, InsightRepository.Json) ?? [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+ }
+
+ public SyntheticCheckResult ToResult() => new()
+ {
+ At = new DateTimeOffset(DateTime.SpecifyKind(RunAt, DateTimeKind.Utc)),
+ Success = Success,
+ DurationMs = DurationMs,
+ Message = Message,
+ Details = Details,
+ CertificateExpiresAt = CertificateExpiresAt is { } c ? new DateTimeOffset(DateTime.SpecifyKind(c, DateTimeKind.Utc)) : null,
+ CertificateSubject = CertificateSubject,
+ ServiceState = ServiceState,
+ Partial = Partial,
+ };
+}
+
+public sealed partial class InsightRepository
+{
+ private const string CheckColumns = """
+ c.CheckId, c.Name, c.Kind, c.ServerId, w.Name AS ServerName, c.SettingsJson, c.SecretCipher, c.SecretKeyId, c.IntervalSeconds, c.TimeoutSeconds, c.Paused,
+ c.CreatedAt, c.CreatedBy, c.UpdatedAt, c.UpdatedBy, c.RemovedAt, c.SecretError, c.LastRunAt, c.LastSuccess, c.LastDurationMs, c.LastMessage, c.ConsecutiveFailures
+ """;
+
+ /// The checks (not removed), by name.
+ public async Task> GetSyntheticChecksAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync($"""
+ SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId
+ WHERE c.RemovedAt IS NULL ORDER BY c.Name;
+ """).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ public async Task GetSyntheticCheckAsync(int id, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.QuerySingleOrDefaultAsync($"""
+ SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId
+ WHERE c.CheckId = @id AND c.RemovedAt IS NULL;
+ """, new { id }).ConfigureAwait(false);
+ }
+
+ /// Adds a check (SQLInsight Web: INSERT only). A name in use throws SqlException 2601.
+ public async Task AddSyntheticCheckAsync(SyntheticCheckInput check, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(check);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteScalarAsync("""
+ INSERT insight.SyntheticChecks (Name, Kind, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused,
+ CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
+ OUTPUT INSERTED.CheckId
+ VALUES (@name, @Kind, @ServerId, @settings, @SecretCipher, @SecretKeyId, @IntervalSeconds, @TimeoutSeconds, @Paused, @now, @by, @now, @by);
+ """, new
+ {
+ name = check.Name.Trim(), check.Kind, check.ServerId, settings = check.Settings.ToJson(), check.SecretCipher, check.SecretKeyId,
+ check.IntervalSeconds, check.TimeoutSeconds, check.Paused, by, now = Utc(now),
+ }).ConfigureAwait(false);
+ }
+
+ /// Changes a check (not its kind). A name in use throws SqlException 2601.
+ public async Task UpdateSyntheticCheckAsync(int id, SyntheticCheckInput check, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(check);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteAsync("""
+ UPDATE insight.SyntheticChecks
+ SET Name = @name, ServerId = @ServerId, SettingsJson = @settings, IntervalSeconds = @IntervalSeconds, TimeoutSeconds = @TimeoutSeconds,
+ SecretCipher = CASE WHEN @ClearSecret = 1 THEN NULL WHEN @SecretCipher IS NOT NULL THEN @SecretCipher ELSE SecretCipher END,
+ SecretKeyId = CASE WHEN @ClearSecret = 1 THEN NULL WHEN @SecretCipher IS NOT NULL THEN @SecretKeyId ELSE SecretKeyId END,
+ SecretError = CASE WHEN @ClearSecret = 1 OR @SecretCipher IS NOT NULL THEN NULL ELSE SecretError END,
+ UpdatedAt = @now, UpdatedBy = @by
+ WHERE CheckId = @id AND RemovedAt IS NULL AND Kind = @Kind;
+ """, new
+ {
+ id, name = check.Name.Trim(), check.Kind, check.ServerId, settings = check.Settings.ToJson(), check.SecretCipher, check.SecretKeyId, check.ClearSecret,
+ check.IntervalSeconds, check.TimeoutSeconds, by, now = Utc(now),
+ }).ConfigureAwait(false) > 0;
+ }
+
+ public async Task SetSyntheticCheckPausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteAsync(
+ "UPDATE insight.SyntheticChecks SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE CheckId = @id AND RemovedAt IS NULL;",
+ new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0;
+ }
+
+ public async Task RemoveSyntheticCheckAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteAsync(
+ "UPDATE insight.SyntheticChecks SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by WHERE CheckId = @id AND RemovedAt IS NULL;",
+ new { id, by, now = Utc(now) }).ConfigureAwait(false) > 0;
+ }
+
+ /// The latest runs of a check, newest first.
+ public async Task> GetSyntheticCheckResultsAsync(int id, int top = 50, DateTimeOffset? from = null,
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ SELECT TOP (@top) ResultId, CheckId, RunAt, Success, DurationMs, Message, DetailsJson, CertificateExpiresAt, CertificateSubject, ServiceState, [Partial]
+ FROM insight.SyntheticCheckResults WHERE CheckId = @id AND (@from IS NULL OR RunAt >= @from)
+ ORDER BY RunAt DESC, ResultId DESC;
+ """, new { id, top, from = from?.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ // ---- The agent ----------------------------------------------------------------------------------------------
+
+ /// Stores a run and the check's latest state (and how many times in a row it has failed).
+ public async Task RecordSyntheticCheckResultAsync(int id, SyntheticCheckResult result, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ INSERT insight.SyntheticCheckResults (CheckId, RunAt, Success, DurationMs, Message, DetailsJson, CertificateExpiresAt, CertificateSubject, ServiceState, [Partial])
+ VALUES (@id, @at, @Success, @DurationMs, LEFT(@Message, 1000), @details, @certificate, LEFT(@CertificateSubject, 400), LEFT(@ServiceState, 32), @Partial);
+ UPDATE insight.SyntheticChecks
+ SET LastRunAt = @at, LastSuccess = @Success, LastDurationMs = @DurationMs, LastMessage = LEFT(@Message, 1000),
+ ConsecutiveFailures = CASE WHEN @Success = 1 THEN 0 ELSE ConsecutiveFailures + 1 END
+ WHERE CheckId = @id;
+ """, new
+ {
+ id, at = Utc(result.At), result.Success, result.DurationMs, result.Message,
+ details = SerializeDetails(result.Details),
+ certificate = result.CertificateExpiresAt is { } c ? Utc(c) : (DateTime?)null, result.CertificateSubject, result.ServiceState, result.Partial,
+ }).ConfigureAwait(false);
+ }
+
+ /// At most 20 details of at most 400 characters, so the JSON always fits (never cut in the middle).
+ private static string? SerializeDetails(IReadOnlyList details)
+ {
+ if (details.Count == 0)
+ {
+ return null;
+ }
+
+ var json = JsonSerializer.Serialize(details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }), Json);
+ return json.Length <= 4000 ? json : null;
+ }
+
+ /// The agent could (null) or could not open a check's stored password.
+ public async Task SetSyntheticCheckSecretErrorAsync(int id, string? error, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ UPDATE insight.SyntheticChecks SET SecretError = LEFT(@error, 1000)
+ WHERE CheckId = @id AND ISNULL(SecretError, N'') <> ISNULL(@error, N'');
+ """, new { id, error }).ConfigureAwait(false);
+ }
+}
diff --git a/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs b/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs
new file mode 100644
index 0000000..51f1900
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs
@@ -0,0 +1,411 @@
+using System.Globalization;
+using System.Text.Json;
+using Dapper;
+using SQLInsight.Core.Servers;
+
+namespace SQLInsight.Repository;
+
+/// A Windows server the agent looks at (insight.WindowsServers).
+public sealed record WindowsServerRow
+{
+ public int ServerId { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string Host { get; init; } = string.Empty;
+ public string Role { get; init; } = ServerRoles.Other;
+ public string Source { get; init; } = ServerSources.Web;
+ public bool Paused { get; init; }
+ public string? WatchedServices { get; init; }
+ public string? IgnoredServices { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public string? CreatedBy { get; init; }
+ public DateTime UpdatedAt { get; init; }
+ public string? UpdatedBy { get; init; }
+ public DateTime? RemovedAt { get; init; }
+ public DateTime? LastContactAt { get; init; }
+ public DateTime? LastErrorAt { get; init; }
+
+ /// Why the agent could not reach the server last time (Danish), when that is newer than the last contact.
+ public string? LastError { get; init; }
+
+ public string? Protocol { get; init; }
+ public string? OperatingSystem { get; init; }
+ public string? OsVersion { get; init; }
+ public DateTime? LastBootUpTime { get; init; }
+ public double? TotalMemoryMb { get; init; }
+ public int? LogicalProcessors { get; init; }
+ public string? AreasJson { get; init; }
+
+ public bool IsSqlHost => Source == ServerSources.SqlHost;
+
+ public IReadOnlyList Watched => InsightRepository.ReadList(WatchedServices);
+
+ public IReadOnlyList Ignored => InsightRepository.ReadList(IgnoredServices);
+
+ /// Which areas the agent could read at the last collection, and why not.
+ public IReadOnlyList Areas =>
+ string.IsNullOrWhiteSpace(AreasJson) ? [] : JsonSerializer.Deserialize>(AreasJson, InsightRepository.Json) ?? [];
+
+ /// The agent reached the server at its last attempt.
+ public bool Reachable => LastContactAt is { } contact && (LastErrorAt is null || LastErrorAt <= contact);
+
+ public ServerIdentity ToIdentity() => new(ServerId, Name, Host, Role) { WatchedServices = Watched, IgnoredServices = Ignored };
+}
+
+/// A new Windows server from SQLInsight Web.
+public sealed record NewWindowsServer(string Name, string Host, string Role, IReadOnlyList WatchedServices, IReadOnlyList IgnoredServices, bool Paused = false);
+
+/// A server's CPU and memory over time.
+public sealed record StoredServerPerformance(DateTime SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct);
+
+/// A Windows event group as stored. is sensitive (local UI only).
+public sealed record StoredServerEvent(long EventRowId, DateTime CollectedAt, string LogName, string Source, long EventId, byte Level, string Category, int Count,
+ DateTime FirstAt, DateTime LastAt, string? Sample);
+
+public sealed partial class InsightRepository
+{
+ private const string ServerColumns = """
+ ServerId, Name, Host, Role, Source, Paused, WatchedServices, IgnoredServices, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy, RemovedAt,
+ LastContactAt, LastErrorAt, LastError, Protocol, OperatingSystem, OsVersion, LastBootUpTime, TotalMemoryMb, LogicalProcessors, AreasJson
+ """;
+
+ internal static IReadOnlyList ReadList(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return [];
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json, Json) ?? [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+
+ private static string? WriteList(IReadOnlyList? list) => list is null || list.Count == 0 ? null : JsonSerializer.Serialize(list, Json);
+
+ /// The Windows servers (not removed), by name.
+ public async Task> GetWindowsServersAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync(
+ $"SELECT {ServerColumns} FROM insight.WindowsServers WHERE RemovedAt IS NULL ORDER BY Name;").ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ public async Task GetWindowsServerAsync(int id, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.QuerySingleOrDefaultAsync(
+ $"SELECT {ServerColumns} FROM insight.WindowsServers WHERE ServerId = @id AND RemovedAt IS NULL;", new { id }).ConfigureAwait(false);
+ }
+
+ /// Adds a server (SQLInsight Web: INSERT only). A name or host in use throws SqlException 2601.
+ public async Task AddWindowsServerAsync(NewWindowsServer server, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteScalarAsync("""
+ INSERT insight.WindowsServers (Name, Host, Role, Source, Paused, WatchedServices, IgnoredServices, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
+ OUTPUT INSERTED.ServerId
+ VALUES (@name, @host, @Role, 'Web', @Paused, @watched, @ignored, @now, @by, @now, @by);
+ """, new
+ {
+ name = server.Name.Trim(), host = ServerHost.Normalize(server.Host), server.Role, server.Paused,
+ watched = WriteList(server.WatchedServices), ignored = WriteList(server.IgnoredServices), by, now = Utc(now),
+ }).ConfigureAwait(false);
+ }
+
+ /// Role and the services to watch and ignore. Allowed for SQL Server hosts too.
+ public async Task UpdateWindowsServerAsync(int id, string role, IReadOnlyList watched, IReadOnlyList ignored, string? by, DateTimeOffset now,
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteAsync("""
+ UPDATE insight.WindowsServers SET Role = @role, WatchedServices = @watched, IgnoredServices = @ignored, UpdatedAt = @now, UpdatedBy = @by
+ WHERE ServerId = @id AND RemovedAt IS NULL;
+ """, new { id, role, watched = WriteList(watched), ignored = WriteList(ignored), by, now = Utc(now) }).ConfigureAwait(false) > 0;
+ }
+
+ public async Task SetWindowsServerPausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteAsync(
+ "UPDATE insight.WindowsServers SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE ServerId = @id AND RemovedAt IS NULL;",
+ new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0;
+ }
+
+ /// Stops looking at a server added in SQLInsight Web. A SQL Server host follows its instances; it can be paused.
+ public async Task RemoveWindowsServerAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var removed = await connection.ExecuteAsync("""
+ UPDATE insight.WindowsServers SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by
+ WHERE ServerId = @id AND RemovedAt IS NULL AND Source = 'Web';
+ """, new { id, by, now = Utc(now) }).ConfigureAwait(false);
+ if (removed > 0)
+ {
+ return ConfigurationChangeOutcome.Changed;
+ }
+
+ return await GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false) is { IsSqlHost: true }
+ ? ConfigurationChangeOutcome.FromConfigFile
+ : ConfigurationChangeOutcome.NotFound;
+ }
+
+ // ---- The agent ----------------------------------------------------------------------------------------------
+
+ ///
+ /// Brings the SQL Server hosts in step with the monitored instances: a host not yet known (from any source) is
+ /// added, one no instance uses any more is removed. Pause and service lists of the rows that stay are kept.
+ ///
+ public async Task SyncSqlHostServersAsync(IReadOnlyList<(string Host, string Name)> hosts, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(hosts);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ var at = Utc(now);
+ var normalized = hosts.Select(h => (Host: ServerHost.Normalize(h.Host), h.Name)).DistinctBy(h => h.Host, StringComparer.OrdinalIgnoreCase).ToList();
+ await connection.ExecuteAsync("""
+ UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: ingen overvåget instans på serveren', UpdatedAt = @at
+ WHERE Source = 'SqlHost' AND RemovedAt IS NULL AND Host NOT IN (SELECT value FROM OPENJSON(@hosts));
+ """, new { hosts = JsonSerializer.Serialize(normalized.Select(h => h.Host)), at }, transaction).ConfigureAwait(false);
+ foreach (var (host, name) in normalized)
+ {
+ await connection.ExecuteAsync("""
+ IF NOT EXISTS (SELECT 1 FROM insight.WindowsServers WITH (UPDLOCK, HOLDLOCK) WHERE Host = @host AND RemovedAt IS NULL)
+ INSERT insight.WindowsServers (Name, Host, Role, Source, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
+ VALUES (CASE WHEN EXISTS (SELECT 1 FROM insight.WindowsServers WHERE Name = @name AND RemovedAt IS NULL) THEN @host ELSE @name END,
+ @host, 'Sql', 'SqlHost', @at, N'SQLInsight Agent (SQL Server-host)', @at, N'SQLInsight Agent (SQL Server-host)');
+ """, new { host, name, at }, transaction).ConfigureAwait(false);
+ }
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Records a successful contact: what the server is, and which areas could be read.
+ public async Task RecordServerContactAsync(int id, ServerOsInfo os, string? protocol, IReadOnlyList? areas, DateTimeOffset now,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(os);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ UPDATE insight.WindowsServers
+ SET LastContactAt = @now, Protocol = @protocol, OperatingSystem = LEFT(@caption, 256), OsVersion = LEFT(@version, 64), LastBootUpTime = @boot,
+ TotalMemoryMb = @memory, LogicalProcessors = @processors, AreasJson = COALESCE(@areas, AreasJson)
+ WHERE ServerId = @id;
+ """, new
+ {
+ id, now = Utc(now), protocol, caption = os.Caption, version = os.Version, boot = os.LastBootUpTime is { } b ? Utc(b) : (DateTime?)null,
+ memory = os.TotalMemoryMb, processors = os.LogicalProcessors, areas = areas is null ? null : JsonSerializer.Serialize(areas, Json),
+ }).ConfigureAwait(false);
+ }
+
+ /// Which areas could be read (and why not), after any area's collection.
+ public async Task RecordServerAreasAsync(int id, IReadOnlyList areas, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(areas);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("UPDATE insight.WindowsServers SET AreasJson = @areas WHERE ServerId = @id;",
+ new { id, areas = JsonSerializer.Serialize(areas, Json) }).ConfigureAwait(false);
+ }
+
+ public async Task RecordServerErrorAsync(int id, string error, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("UPDATE insight.WindowsServers SET LastErrorAt = @now, LastError = LEFT(@error, 1000) WHERE ServerId = @id;",
+ new { id, error, now = Utc(now) }).ConfigureAwait(false);
+ }
+
+ public async Task SaveServerPerformanceAsync(int id, ServerPerformanceSample sample, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(sample);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ IF NOT EXISTS (SELECT 1 FROM insight.ServerPerformance WHERE ServerId = @id AND SampledAt = @at)
+ INSERT insight.ServerPerformance (ServerId, SampledAt, CpuPercent, AvailableMb, AvailablePct, PagesPerSec, CommitPct)
+ VALUES (@id, @at, @cpu, @available, @availablePct, @pages, @commit);
+ """, new
+ {
+ id, at = Utc(sample.At), cpu = sample.CpuPercent, available = sample.AvailableMb, availablePct = sample.AvailablePercent, pages = sample.PagesPerSecond,
+ commit = sample.CommitPercent,
+ }).ConfigureAwait(false);
+ }
+
+ /// The samples since , oldest first: the agent's window after a restart, and the charts.
+ public async Task> GetServerPerformanceAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ SELECT SampledAt, CAST(CpuPercent AS float) AS CpuPercent, CAST(AvailableMb AS float) AS AvailableMb, CAST(AvailablePct AS float) AS AvailablePct,
+ CAST(PagesPerSec AS float) AS PagesPerSec, CAST(CommitPct AS float) AS CommitPct
+ FROM insight.ServerPerformance WHERE ServerId = @id AND SampledAt >= @from ORDER BY SampledAt;
+ """, new { id, from = from.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ public async Task SaveServerDisksAsync(int id, DateTimeOffset at, IReadOnlyList disks, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(disks);
+ if (disks.Count == 0)
+ {
+ return;
+ }
+
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ INSERT insight.ServerDiskSamples (ServerId, Volume, SampledAt, SizeGb, FreeGb)
+ SELECT @id, Volume, @at, SizeGb, FreeGb
+ FROM OPENJSON(@json) WITH (Volume nvarchar(260), SizeGb float, FreeGb float) j
+ WHERE NOT EXISTS (SELECT 1 FROM insight.ServerDiskSamples s WHERE s.ServerId = @id AND s.Volume = j.Volume AND s.SampledAt = @at);
+ """, new
+ {
+ id, at = Utc(at),
+ json = JsonSerializer.Serialize(disks.GroupBy(d => d.Volume, StringComparer.OrdinalIgnoreCase).Select(g => g.First())
+ .Select(d => new { Volume = d.Volume.Length > 260 ? d.Volume[..260] : d.Volume, d.SizeGb, d.FreeGb }), Json),
+ }).ConfigureAwait(false);
+ }
+
+ /// Free space per volume since , averaged per hour: what the forecast is computed from.
+ public async Task>> GetServerDiskHistoryAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync<(string Volume, DateTime Hour, double SizeGb, double FreeGb)>("""
+ SELECT Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], MAX(SizeGb), AVG(FreeGb)
+ FROM insight.ServerDiskSamples WHERE ServerId = @id AND SampledAt >= @from
+ GROUP BY Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0)
+ ORDER BY Volume, [Hour];
+ """, new { id, from = from.UtcDateTime }).ConfigureAwait(false);
+ return rows.GroupBy(r => r.Volume, StringComparer.OrdinalIgnoreCase).ToDictionary(
+ g => g.Key,
+ g => (IReadOnlyList)g.Select(r => new DiskHistoryPoint(new DateTimeOffset(DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc)), r.SizeGb, r.FreeGb)).ToList(),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ public async Task SaveServerEventsAsync(int id, ServerEventSnapshot snapshot, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (snapshot.Groups.Count == 0)
+ {
+ return;
+ }
+
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ INSERT insight.ServerEvents (ServerId, CollectedAt, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample)
+ SELECT @id, @at, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample
+ FROM OPENJSON(@json) WITH (LogName nvarchar(64), Source nvarchar(256), EventId bigint, [Level] tinyint, Category varchar(32), [Count] int,
+ FirstAt datetime2(0), LastAt datetime2(0), Sample nvarchar(500));
+ """, new
+ {
+ id,
+ at = Utc(snapshot.CapturedAt),
+ json = JsonSerializer.Serialize(snapshot.Groups.Select(g => new
+ {
+ LogName = Cut(g.Log, 64),
+ Source = Cut(g.Source, 256),
+ g.EventId,
+ Level = (byte)Math.Clamp(g.Level, 0, 255),
+ Category = g.Category.ToString(),
+ g.Count,
+ FirstAt = Iso(g.FirstAt),
+ LastAt = Iso(g.LastAt),
+ Sample = g.Sample is null ? null : Cut(g.Sample, 500),
+ }), Json),
+ }).ConfigureAwait(false);
+
+ static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
+ }
+
+ private static string Cut(string value, int length) => value.Length <= length ? value : value[..length];
+
+ /// Event groups last seen in [from, to], newest first.
+ public async Task> GetServerEventsAsync(int id, DateTimeOffset from, DateTimeOffset to, int top = 500,
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ SELECT TOP (@top) EventRowId, CollectedAt, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample
+ FROM insight.ServerEvents WHERE ServerId = @id AND LastAt >= @from AND FirstAt <= @to
+ ORDER BY LastAt DESC, EventRowId DESC;
+ """, new { id, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ /// The latest collection of an area, as JSON.
+ public async Task SaveServerSnapshotAsync(int id, string area, object data, DateTimeOffset capturedAt, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await connection.ExecuteAsync("""
+ MERGE insight.ServerSnapshots AS t
+ USING (SELECT @id AS ServerId, @area AS Area) AS s ON t.ServerId = s.ServerId AND t.Area = s.Area
+ WHEN MATCHED THEN UPDATE SET CapturedAt = @at, Json = @json
+ WHEN NOT MATCHED THEN INSERT (ServerId, Area, CapturedAt, Json) VALUES (@id, @area, @at, @json);
+ """, new { id, area, at = Utc(capturedAt), json = JsonSerializer.Serialize(data, data.GetType(), Json) }).ConfigureAwait(false);
+ }
+
+ public async Task GetServerSnapshotAsync(int id, string area, CancellationToken cancellationToken = default)
+ where T : class
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var json = await connection.QuerySingleOrDefaultAsync(
+ "SELECT Json FROM insight.ServerSnapshots WHERE ServerId = @id AND Area = @area;", new { id, area }).ConfigureAwait(false);
+ if (json is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, Json);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Deletes server samples, events and check results past retention, and resolved server findings, in small batches.
+ /// Called hourly by the agent.
+ ///
+ public async Task RunServerMaintenanceAsync(ServerRetention retention, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(retention);
+ var utc = now.UtcDateTime;
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.ServerPerformance", "SampledAt < @cutoff", utc.AddDays(-retention.SampleDays)).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.ServerDiskSamples", "SampledAt < @cutoff", utc.AddDays(-retention.DiskHistoryDays)).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.ServerEvents", "LastAt < @cutoff", utc.AddDays(-retention.EventDays)).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.SyntheticCheckResults", "RunAt < @cutoff", utc.AddDays(-retention.CheckResultDays)).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.ServerFindings", "ResolvedAt < @cutoff", utc.AddDays(-retention.ResolvedFindingDays)).ConfigureAwait(false);
+
+ // Removed servers and checks: their samples and snapshots go at once; findings and history follow retention.
+ await connection.ExecuteAsync("""
+ DELETE s FROM insight.ServerSnapshots s JOIN insight.WindowsServers w ON w.ServerId = s.ServerId WHERE w.RemovedAt IS NOT NULL;
+ UPDATE f SET ResolvedAt = @now FROM insight.ServerFindings f
+ WHERE f.ResolvedAt IS NULL AND ((f.Scope = 'Server' AND EXISTS (SELECT 1 FROM insight.WindowsServers w WHERE w.ServerId = f.ScopeId AND (w.RemovedAt IS NOT NULL OR w.Paused = 1)))
+ OR (f.Scope = 'Check' AND EXISTS (SELECT 1 FROM insight.SyntheticChecks c WHERE c.CheckId = f.ScopeId AND (c.RemovedAt IS NOT NULL OR c.Paused = 1))));
+ """, new { now = Utc(now) }, commandTimeout: 300).ConfigureAwait(false);
+ }
+}
+
+/// How long Windows server data is kept (SQLInsight:Servers and SQLInsight:SyntheticChecks).
+public sealed record ServerRetention
+{
+ /// Per-minute CPU and memory.
+ public int SampleDays { get; init; } = 14;
+
+ /// Free space per volume: must cover the forecast's history.
+ public int DiskHistoryDays { get; init; } = 35;
+
+ public int EventDays { get; init; } = 90;
+
+ public int CheckResultDays { get; init; } = 14;
+
+ public int ResolvedFindingDays { get; init; } = 365;
+}
diff --git a/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql b/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql
new file mode 100644
index 0000000..09ee7f1
--- /dev/null
+++ b/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql
@@ -0,0 +1,196 @@
+/* 1.5: Windows servers and synthetic checks.
+
+ WindowsServers the Windows servers the agent looks at: the computers behind the monitored SQL Server
+ instances (Source 'SqlHost', kept in step by the agent) and servers added in SQLInsight Web
+ (AOS and other application servers, file servers). The agent reads them remotely, as its own
+ identity, with least privilege (db/permissions/grant-windows-server.ps1); it never changes them.
+ ServerPerformance CPU, available memory, paging and commit per minute. Kept SQLInsight:Servers:SampleDays.
+ ServerDiskSamples free space per volume every 15 minutes: the history the "fuld om ca. N dage" forecast is
+ computed from. Kept SQLInsight:Servers:DiskHistoryDays.
+ ServerEvents errors and critical events from the System and Application logs, summed per collection,
+ source and event id like insight.ErrorLogEvents. Sample is a message: local only. Kept EventDays.
+ ServerSnapshots the latest collection per area (services, tasks, disks with forecasts …) for display, and so a
+ restarted agent continues where it stopped.
+ SyntheticChecks checks the agent runs on a schedule: TCP port, HTTP(S), SQL login + query, DNS, Dynamics AX
+ logon. A SQL login password is sealed with the agent's public key (insight.AgentKeys), as in 1.3.
+ SyntheticCheckResults every run, kept SQLInsight:SyntheticChecks:ResultDays.
+ ServerFindings, findings and rule outcomes of the Windows server rules (Scope 'Server') and synthetic check rules
+ ServerRuleStates (Scope 'Check'), like insight.Findings and insight.RuleStates for instances. */
+CREATE TABLE insight.WindowsServers
+(
+ ServerId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_WindowsServers PRIMARY KEY,
+ Name nvarchar(128) NOT NULL,
+ Host nvarchar(255) NOT NULL,
+ Role varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Role DEFAULT ('Other')
+ CONSTRAINT CK_WindowsServers_Role CHECK (Role IN ('Sql', 'Application', 'File', 'Other')),
+ Source varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Source DEFAULT ('Web')
+ CONSTRAINT CK_WindowsServers_Source CHECK (Source IN ('Web', 'SqlHost')),
+ Paused bit NOT NULL CONSTRAINT DF_WindowsServers_Paused DEFAULT (0),
+ WatchedServices nvarchar(4000) NULL,
+ IgnoredServices nvarchar(4000) NULL,
+ CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_CreatedAt DEFAULT (SYSUTCDATETIME()),
+ CreatedBy nvarchar(256) NULL,
+ UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_UpdatedAt DEFAULT (SYSUTCDATETIME()),
+ UpdatedBy nvarchar(256) NULL,
+ RemovedAt datetime2(0) NULL,
+ RemovedBy nvarchar(256) NULL,
+ /* What the agent saw. */
+ LastContactAt datetime2(0) NULL,
+ LastErrorAt datetime2(0) NULL,
+ LastError nvarchar(1000) NULL,
+ Protocol varchar(16) NULL,
+ OperatingSystem nvarchar(256) NULL,
+ OsVersion nvarchar(64) NULL,
+ LastBootUpTime datetime2(0) NULL,
+ TotalMemoryMb float NULL,
+ LogicalProcessors int NULL,
+ AreasJson nvarchar(4000) NULL
+);
+CREATE UNIQUE INDEX UQ_WindowsServers_Host ON insight.WindowsServers (Host) WHERE RemovedAt IS NULL;
+CREATE UNIQUE INDEX UQ_WindowsServers_Name ON insight.WindowsServers (Name) WHERE RemovedAt IS NULL;
+
+CREATE TABLE insight.ServerPerformance
+(
+ ServerId int NOT NULL,
+ SampledAt datetime2(0) NOT NULL,
+ CpuPercent real NULL,
+ AvailableMb real NULL,
+ AvailablePct real NULL,
+ PagesPerSec real NULL,
+ CommitPct real NULL,
+ CONSTRAINT PK_ServerPerformance PRIMARY KEY (ServerId, SampledAt)
+);
+
+CREATE TABLE insight.ServerDiskSamples
+(
+ ServerId int NOT NULL,
+ Volume nvarchar(260) NOT NULL,
+ SampledAt datetime2(0) NOT NULL,
+ SizeGb float NOT NULL,
+ FreeGb float NOT NULL,
+ CONSTRAINT PK_ServerDiskSamples PRIMARY KEY (ServerId, Volume, SampledAt)
+);
+
+CREATE TABLE insight.ServerEvents
+(
+ EventRowId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ServerEvents PRIMARY KEY NONCLUSTERED,
+ ServerId int NOT NULL,
+ CollectedAt datetime2(0) NOT NULL,
+ LogName nvarchar(64) NOT NULL,
+ Source nvarchar(256) NOT NULL,
+ EventId bigint NOT NULL,
+ [Level] tinyint NOT NULL,
+ Category varchar(32) NOT NULL,
+ [Count] int NOT NULL,
+ FirstAt datetime2(0) NOT NULL,
+ LastAt datetime2(0) NOT NULL,
+ Sample nvarchar(500) NULL
+);
+CREATE CLUSTERED INDEX CX_ServerEvents ON insight.ServerEvents (ServerId, LastAt);
+
+CREATE TABLE insight.ServerSnapshots
+(
+ ServerId int NOT NULL,
+ Area varchar(32) NOT NULL,
+ CapturedAt datetime2(0) NOT NULL,
+ Json nvarchar(max) NOT NULL,
+ CONSTRAINT PK_ServerSnapshots PRIMARY KEY (ServerId, Area)
+);
+
+CREATE TABLE insight.SyntheticChecks
+(
+ CheckId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SyntheticChecks PRIMARY KEY,
+ Name nvarchar(128) NOT NULL,
+ Kind varchar(16) NOT NULL CONSTRAINT CK_SyntheticChecks_Kind CHECK (Kind IN ('Tcp', 'Http', 'Sql', 'Dns', 'AxLogon')),
+ ServerId int NULL CONSTRAINT FK_SyntheticChecks_Server REFERENCES insight.WindowsServers (ServerId),
+ SettingsJson nvarchar(4000) NOT NULL,
+ SecretCipher varbinary(2048) NULL,
+ SecretKeyId varchar(64) NULL,
+ IntervalSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Interval DEFAULT (300)
+ CONSTRAINT CK_SyntheticChecks_Interval CHECK (IntervalSeconds BETWEEN 60 AND 86400),
+ TimeoutSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Timeout DEFAULT (15)
+ CONSTRAINT CK_SyntheticChecks_Timeout CHECK (TimeoutSeconds BETWEEN 1 AND 120),
+ Paused bit NOT NULL CONSTRAINT DF_SyntheticChecks_Paused DEFAULT (0),
+ CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_SyntheticChecks_CreatedAt DEFAULT (SYSUTCDATETIME()),
+ CreatedBy nvarchar(256) NULL,
+ UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_SyntheticChecks_UpdatedAt DEFAULT (SYSUTCDATETIME()),
+ UpdatedBy nvarchar(256) NULL,
+ RemovedAt datetime2(0) NULL,
+ RemovedBy nvarchar(256) NULL,
+ SecretError nvarchar(1000) NULL,
+ /* What the agent saw. */
+ LastRunAt datetime2(0) NULL,
+ LastSuccess bit NULL,
+ LastDurationMs int NULL,
+ LastMessage nvarchar(1000) NULL,
+ ConsecutiveFailures int NOT NULL CONSTRAINT DF_SyntheticChecks_Failures DEFAULT (0)
+);
+CREATE UNIQUE INDEX UQ_SyntheticChecks_Name ON insight.SyntheticChecks (Name) WHERE RemovedAt IS NULL;
+
+CREATE TABLE insight.SyntheticCheckResults
+(
+ ResultId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SyntheticCheckResults PRIMARY KEY NONCLUSTERED,
+ CheckId int NOT NULL,
+ RunAt datetime2(0) NOT NULL,
+ Success bit NOT NULL,
+ DurationMs int NOT NULL,
+ Message nvarchar(1000) NOT NULL,
+ DetailsJson nvarchar(4000) NULL,
+ CertificateExpiresAt datetime2(0) NULL,
+ CertificateSubject nvarchar(400) NULL,
+ ServiceState varchar(32) NULL,
+ [Partial] bit NOT NULL CONSTRAINT DF_SyntheticCheckResults_Partial DEFAULT (0)
+);
+CREATE CLUSTERED INDEX CX_SyntheticCheckResults ON insight.SyntheticCheckResults (CheckId, RunAt);
+
+CREATE TABLE insight.ServerFindings
+(
+ FindingId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ServerFindings PRIMARY KEY,
+ Scope varchar(8) NOT NULL CONSTRAINT CK_ServerFindings_Scope CHECK (Scope IN ('Server', 'Check')),
+ ScopeId int NOT NULL,
+ RuleId varchar(40) NOT NULL,
+ Subject nvarchar(400) NOT NULL,
+ Severity tinyint NOT NULL,
+ Title nvarchar(1000) NOT NULL,
+ Explanation nvarchar(max) NOT NULL,
+ Recommendation nvarchar(max) NOT NULL,
+ EvidenceJson nvarchar(max) NOT NULL,
+ ReferencesJson nvarchar(max) NOT NULL,
+ FirstSeenAt datetime2(0) NOT NULL,
+ LastSeenAt datetime2(0) NOT NULL,
+ ResolvedAt datetime2(0) NULL
+);
+CREATE UNIQUE INDEX UX_ServerFindings_Active ON insight.ServerFindings (Scope, ScopeId, RuleId, Subject) WHERE ResolvedAt IS NULL;
+CREATE INDEX IX_ServerFindings_Resolved ON insight.ServerFindings (ResolvedAt) WHERE ResolvedAt IS NOT NULL;
+
+CREATE TABLE insight.ServerRuleStates
+(
+ Scope varchar(8) NOT NULL,
+ ScopeId int NOT NULL,
+ RuleId varchar(40) NOT NULL,
+ RuleTitle nvarchar(400) NOT NULL,
+ Outcome tinyint NOT NULL,
+ Reason nvarchar(max) NULL,
+ EvaluatedAt datetime2(0) NOT NULL,
+ CONSTRAINT PK_ServerRuleStates PRIMARY KEY (Scope, ScopeId, RuleId)
+);
+
+/* Requests about a Windows server or a synthetic check name it in TargetId. */
+ALTER TABLE insight.AgentRequests ADD TargetId int NULL;
+ALTER TABLE insight.AgentRequests DROP CONSTRAINT CK_AgentRequests_Kind;
+ALTER TABLE insight.AgentRequests ADD CONSTRAINT CK_AgentRequests_Kind
+ CHECK (Kind IN ('ConnectionTest', 'DatabaseDiscovery', 'InstanceDiscovery', 'ServerTest', 'SyntheticCheckRun'));
+GO
+
+CREATE INDEX IX_AgentRequests_Target ON insight.AgentRequests (Kind, TargetId, RequestId) WHERE TargetId IS NOT NULL;
+GO
+
+/* SQLInsight Web only has db_datareader. As in 0016 it gets exactly what it needs to change the configuration: add
+ servers and checks, change what may be changed, and remove (RemovedAt, no DELETE). Nothing the agent records. Host,
+ Name of a server and Kind of a check are fixed once created. Who may change it in the web UI is decided by
+ SQLInsight:Web:AdminGroups. */
+GRANT INSERT ON insight.WindowsServers TO public;
+GRANT UPDATE ON insight.WindowsServers (Role, Paused, WatchedServices, IgnoredServices, UpdatedAt, UpdatedBy, RemovedAt, RemovedBy) TO public;
+GRANT INSERT ON insight.SyntheticChecks TO public;
+GRANT UPDATE ON insight.SyntheticChecks (Name, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused,
+ UpdatedAt, UpdatedBy, RemovedAt, RemovedBy, SecretError) TO public;
diff --git a/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs b/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs
new file mode 100644
index 0000000..13f7d9b
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs
@@ -0,0 +1,252 @@
+using Microsoft.Data.SqlClient;
+using SQLInsight.Core.Findings;
+using SQLInsight.Core.Servers;
+using SQLInsight.Core.SyntheticChecks;
+using SQLInsight.Repository;
+using SQLInsight.Rules.Engine;
+
+namespace SQLInsight.IntegrationTests;
+
+///
+/// Windows servers and synthetic checks in the repository (0018): what SQLInsight Web may change with its read-only
+/// login, the SQL Server hosts kept in step by the agent, samples, events, check results, findings and retention.
+///
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class ServerRepositoryTests(SqlServerFixture sql) : IAsyncLifetime
+{
+ private readonly string _database = "SQLInsightSrv_" + Guid.NewGuid().ToString("N")[..8];
+ private InsightRepository _repository = null!;
+ private string _webConnectionString = string.Empty;
+ private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow;
+
+ public async Task InitializeAsync()
+ {
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
+ _repository = new InsightRepository(sql.DatabaseConnectionString(_database));
+ await _repository.MigrateAsync();
+ }
+
+ public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
+
+ private async Task ReadOnlyRepositoryAsync()
+ {
+ var login = "web_" + Guid.NewGuid().ToString("N")[..8];
+ const string password = "Read-Only-Login-1!";
+ await sql.ExecuteAsSaAsync($"""
+ CREATE LOGIN [{login}] WITH PASSWORD = N'{password}', CHECK_POLICY = OFF;
+ USE [{_database}]; CREATE USER [{login}] FOR LOGIN [{login}]; ALTER ROLE db_datareader ADD MEMBER [{login}];
+ """);
+ _webConnectionString = new SqlConnectionStringBuilder(sql.DatabaseConnectionString(_database)) { UserID = login, Password = password, IntegratedSecurity = false }
+ .ConnectionString;
+ return new InsightRepository(_webConnectionString);
+ }
+
+ private async Task ExecuteAsWebAsync(string sqlText)
+ {
+ await using var connection = new SqlConnection(_webConnectionString);
+ await connection.OpenAsync();
+ await using var command = connection.CreateCommand();
+ command.CommandText = sqlText;
+ await command.ExecuteNonQueryAsync();
+ }
+
+ private static SyntheticCheckInput Check(string name, int? serverId = null, byte[]? secret = null) =>
+ new(name, SyntheticCheckKind.Tcp, serverId, new SyntheticCheckSettings { Host = "aos01", Port = 2712, ServiceName = serverId is null ? null : "AOS60$01" }, secret,
+ secret is null ? null : "key-1", 300, 15);
+
+ [Fact]
+ public async Task Web_login_can_add_and_change_servers_and_checks_but_nothing_the_agent_records()
+ {
+ var web = await ReadOnlyRepositoryAsync();
+
+ var server = await web.AddWindowsServerAsync(new NewWindowsServer("AOS01", "AOS01.firma.local", ServerRoles.Application, ["AOS60$01"], []), @"FIRMA\anna", Now);
+ Assert.True(await web.UpdateWindowsServerAsync(server, ServerRoles.Application, ["AOS60$01", "W3SVC"], ["MyBatch"], @"FIRMA\anna", Now));
+ Assert.True(await web.SetWindowsServerPausedAsync(server, true, @"FIRMA\anna", Now));
+ var check = await web.AddSyntheticCheckAsync(Check("AOS01 port", server, [1, 2, 3]), @"FIRMA\anna", Now);
+ Assert.True(await web.UpdateSyntheticCheckAsync(check, Check("AOS01 AOS-port", server) with { IntervalSeconds = 120 }, @"FIRMA\anna", Now));
+ Assert.True(await web.SetSyntheticCheckPausedAsync(check, true, @"FIRMA\anna", Now));
+ var request = await web.AddServerRequestAsync(ServerRequestKind.ServerTest, server, @"FIRMA\anna", Now);
+
+ var row = (await _repository.GetWindowsServerAsync(server))!;
+ Assert.Equal(("aos01.firma.local", ServerRoles.Application, true, ServerSources.Web), (row.Host, row.Role, row.Paused, row.Source));
+ Assert.Equal(["AOS60$01", "W3SVC"], row.Watched);
+ Assert.Equal(["MyBatch"], row.Ignored);
+ var checkRow = (await _repository.GetSyntheticCheckAsync(check))!;
+ Assert.Equal(("AOS01 AOS-port", 120, true, "AOS01"), (checkRow.Name, checkRow.IntervalSeconds, checkRow.HasSecret, checkRow.ServerName));
+ Assert.Equal("AOS60$01", checkRow.Settings.ServiceName);
+ Assert.Equal(server, await web.GetRequestTargetAsync(request));
+ Assert.Equal(request, (await web.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, server))!.RequestId);
+
+ // What only the agent does is refused for the web login.
+ await Assert.ThrowsAsync(() => web.RecordServerContactAsync(server, new ServerOsInfo { Caption = "x" }, "DCOM", null, Now));
+ await Assert.ThrowsAsync(() => web.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now, 1, 1, 1, 1, 1)));
+ await Assert.ThrowsAsync(() => web.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now, Success = true, Message = "ok" }));
+ await Assert.ThrowsAsync(() => web.RecordServerAreasAsync(server, []));
+ await Assert.ThrowsAsync(() => web.SaveServerSnapshotAsync(server, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = Now }, Now));
+ await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.WindowsServers SET Host = N'evil' WHERE ServerId = {server}"));
+ await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.SyntheticChecks SET Kind = 'Sql' WHERE CheckId = {check}"));
+ await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"DELETE FROM insight.SyntheticChecks WHERE CheckId = {check}"));
+ await Assert.ThrowsAsync(() => ExecuteAsWebAsync("DELETE FROM insight.ServerFindings"));
+
+ Assert.True(await web.RemoveSyntheticCheckAsync(check, @"FIRMA\anna", Now));
+ Assert.Equal(ConfigurationChangeOutcome.Changed, await web.RemoveWindowsServerAsync(server, @"FIRMA\anna", Now));
+ Assert.Empty(await _repository.GetWindowsServersAsync());
+ Assert.Empty(await _repository.GetSyntheticChecksAsync());
+ }
+
+ [Fact]
+ public async Task Names_and_hosts_are_unique_among_servers_in_use()
+ {
+ await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, Now);
+
+ var host = await Assert.ThrowsAsync(() => _repository.AddWindowsServerAsync(new NewWindowsServer("Fil", "FIL01", ServerRoles.File, [], []), null, Now));
+ var name = await Assert.ThrowsAsync(() => _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil02", ServerRoles.File, [], []), null, Now));
+ Assert.True(host.Number is 2601 or 2627);
+ Assert.True(name.Number is 2601 or 2627);
+ }
+
+ [Fact]
+ public async Task Sql_server_hosts_follow_the_instances_and_keep_their_settings()
+ {
+ await _repository.AddWindowsServerAsync(new NewWindowsServer("SQL02 (manuelt)", "sql02.firma.local", ServerRoles.Sql, [], []), null, Now);
+ await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01"), ("SQL02.firma.local", "SQL02"), ("sql01.firma.local", "SQL01")], Now);
+
+ var servers = await _repository.GetWindowsServersAsync();
+ Assert.Equal(["SQL01", "SQL02 (manuelt)"], servers.Select(s => s.Name).ToList());
+ var sql01 = servers.Single(s => s.Name == "SQL01");
+ Assert.True(sql01.IsSqlHost);
+ Assert.Equal(ServerRoles.Sql, sql01.Role);
+ await _repository.UpdateWindowsServerAsync(sql01.ServerId, ServerRoles.Sql, ["MSSQLSERVER"], [], "anna", Now);
+
+ Assert.Equal(ConfigurationChangeOutcome.FromConfigFile, await _repository.RemoveWindowsServerAsync(sql01.ServerId, "anna", Now));
+ await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], Now);
+ Assert.Equal(["MSSQLSERVER"], (await _repository.GetWindowsServerAsync(sql01.ServerId))!.Watched);
+
+ await _repository.SyncSqlHostServersAsync([], Now);
+ Assert.Equal(["SQL02 (manuelt)"], (await _repository.GetWindowsServersAsync()).Select(s => s.Name).ToList());
+ }
+
+ [Fact]
+ public async Task Samples_disk_history_events_and_snapshots_round_trip()
+ {
+ var id = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now);
+ await _repository.RecordServerContactAsync(id, new ServerOsInfo { Caption = "Windows Server 2019", Version = "10.0.17763", TotalMemoryMb = 16384, LogicalProcessors = 4,
+ LastBootUpTime = Now.AddDays(-3) }, "WS-Man", [new ServerAreaStatus(ServerArea.Events, false, "Adgang nægtet", ServerArea.Right(ServerArea.Events))], Now);
+ await _repository.RecordServerErrorAsync(id, "RPC-serveren er ikke tilgængelig.", Now.AddMinutes(-5));
+ for (var m = 0; m < 5; m++)
+ {
+ await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(Now.AddMinutes(-m), 10 + m, 4000, 16384, 3, 50));
+ }
+
+ await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(Now, 99, 1, 1, 1, 1)); // same minute: kept once
+ for (var h = 0; h < 60; h++)
+ {
+ await _repository.SaveServerDisksAsync(id, Now.AddHours(-h), [new ServerDisk(@"D:\", "Data", "NTFS", 100, 20 + (h * 0.5)), new ServerDisk(@"C:\", null, "NTFS", 80, 40)]);
+ }
+
+ var (groups, _) = ServerEventAnalysis.Group([new ServerEventEntry("System", "disk", 153, 2, Now.AddMinutes(-3), "Disk 1 svarede ikke")], []);
+ await _repository.SaveServerEventsAsync(id, new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now, Groups = groups, Recent = groups });
+ await _repository.SaveServerSnapshotAsync(id, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = Now, Services = [new ServerService("W3SVC", "IIS", "Running", "Auto")] }, Now);
+
+ var row = (await _repository.GetWindowsServerAsync(id))!;
+ Assert.True(row.Reachable);
+ Assert.Equal(("WS-Man", "Windows Server 2019", 4), (row.Protocol, row.OperatingSystem, row.LogicalProcessors));
+ Assert.Equal("Adgang nægtet", Assert.Single(row.Areas).Detail);
+ var performance = await _repository.GetServerPerformanceAsync(id, Now.AddHours(-1));
+ Assert.Equal(5, performance.Count);
+ Assert.Equal(10, performance[^1].CpuPercent!.Value, 3);
+ var history = await _repository.GetServerDiskHistoryAsync(id, Now.AddDays(-14));
+ Assert.Equal(2, history.Count);
+ Assert.InRange(history[@"D:\"].Count, 60, 61);
+ var forecast = DiskForecaster.Forecast(@"D:\", history[@"D:\"], Now);
+ Assert.Equal(20 / 12d, forecast.DaysUntilFull!.Value, 1); // 12 GB a day, 20 GB free
+ var events = await _repository.GetServerEventsAsync(id, Now.AddDays(-1), Now);
+ Assert.Equal(("disk", 153, "Disk", "Disk 1 svarede ikke"), (events[0].Source, events[0].EventId, events[0].Category, events[0].Sample));
+ var services = await _repository.GetServerSnapshotAsync(id, ServerArea.Services);
+ Assert.Equal("W3SVC", Assert.Single(services!.Services).Name);
+ }
+
+ private static RuleResult Found(string ruleId, params Finding[] findings) =>
+ new() { RuleId = ruleId, RuleTitle = ruleId, Outcome = findings.Length == 0 ? RuleOutcome.Passed : RuleOutcome.Findings, Findings = findings };
+
+ private static Finding Finding(string ruleId, string subject, Severity severity = Severity.High) => new()
+ {
+ RuleId = ruleId, Subject = subject, Severity = severity, Title = "t " + subject, Explanation = "e", Recommendation = "r",
+ Evidence = [new Evidence("Eksempel", "\\\\fil01\\hemmeligt", Sensitive: true)],
+ };
+
+ [Fact]
+ public async Task Findings_open_update_resolve_per_server_and_check_and_survive_a_not_measurable_round()
+ {
+ var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now);
+ var check = await _repository.AddSyntheticCheckAsync(Check("AOS port"), null, Now);
+
+ var first = await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server,
+ [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\"), Finding("SQLI-WIN-005", @"disk: E:\"))], Now);
+ await _repository.SaveScopedRuleResultsAsync(FindingScope.Check, check, [Found("SQLI-SYN-001", Finding("SQLI-SYN-001", "check"))], Now);
+ Assert.Equal(2, first.Opened.Count);
+
+ // Not measurable (e.g. rights removed): the findings stay open.
+ await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server,
+ [new RuleResult { RuleId = "SQLI-WIN-005", RuleTitle = "x", Outcome = RuleOutcome.NotMeasurable, Reason = "Adgang nægtet" }], Now.AddMinutes(1));
+ Assert.Equal(2, (await _repository.GetServerFindingsAsync(FindingScope.Server, server)).Count);
+ Assert.Equal("Adgang nægtet", Assert.Single(await _repository.GetServerRuleStatesAsync(FindingScope.Server, server)).Reason);
+
+ var later = await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\", Severity.Critical))], Now.AddMinutes(2));
+ Assert.Empty(later.Opened);
+ Assert.Equal(@"disk: E:\", Assert.Single(later.Resolved).Subject);
+
+ var active = await _repository.GetServerFindingsAsync();
+ Assert.Equal(2, active.Count);
+ Assert.Equal(("AOS01", Severity.Critical), (active.Single(f => f.Scope == FindingScope.Server).ScopeName, active.Single(f => f.Scope == FindingScope.Server).Severity));
+ Assert.Equal("AOS port", active.Single(f => f.Scope == FindingScope.Check).ScopeName);
+ Assert.True(active[0].Evidence[0].Sensitive);
+ var counts = await _repository.GetServerFindingCountsAsync();
+ Assert.Equal(1, counts[(FindingScope.Server, server)][Severity.Critical]);
+ var resolved = Assert.Single(await _repository.GetServerFindingsAsync(FindingScope.Server, server, FindingStatus.Resolved));
+ Assert.Equal(resolved.FindingId, (await _repository.GetServerFindingAsync(resolved.FindingId))!.FindingId);
+
+ // Back again: a new occurrence, with the old one as history.
+ var again = await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server,
+ [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\"), Finding("SQLI-WIN-005", @"disk: E:\"))], Now.AddMinutes(3));
+ var reopened = Assert.Single(again.Opened);
+ Assert.Single(await _repository.GetServerFindingHistoryAsync((await _repository.GetServerFindingAsync(reopened.FindingId))!));
+ }
+
+ [Fact]
+ public async Task Check_results_count_failures_in_a_row_and_retention_bounds_everything()
+ {
+ var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now);
+ var check = await _repository.AddSyntheticCheckAsync(Check("AOS port", server), null, Now);
+ await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now.AddMinutes(-10), Success = true, DurationMs = 5, Message = "ok" });
+ await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult
+ {
+ At = Now.AddMinutes(-5), Success = false, DurationMs = 15000, Message = "Timeout", ServiceState = "Running",
+ Details = [new SyntheticCheckDetail("Fejl", new string('x', 3000))],
+ });
+ await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now, Success = false, DurationMs = 15000, Message = "Timeout", Partial = true });
+
+ var row = (await _repository.GetSyntheticCheckAsync(check))!;
+ Assert.Equal((false, 2, "Timeout"), (row.LastSuccess, row.ConsecutiveFailures, row.LastMessage));
+ var results = await _repository.GetSyntheticCheckResultsAsync(check);
+ Assert.Equal([false, false, true], results.Select(r => r.Success).ToList());
+ Assert.Equal("Running", results[1].ServiceState);
+ Assert.Equal(400, results[1].Details.Single().Value.Length);
+ Assert.True(results[0].ToResult().Partial);
+
+ // Old data goes; removed and paused servers' findings are resolved.
+ await _repository.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now.AddDays(-20), 1, 1, 1, 1, 1));
+ await _repository.SaveServerDisksAsync(server, Now.AddDays(-40), [new ServerDisk(@"C:\", null, null, 1, 1)]);
+ await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now.AddDays(-20), Success = true, Message = "gammel" });
+ await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, [Found("SQLI-WIN-001", Finding("SQLI-WIN-001", "cpu"))], Now);
+ await _repository.SetWindowsServerPausedAsync(server, true, null, Now);
+
+ await _repository.RunServerMaintenanceAsync(new ServerRetention(), Now);
+
+ Assert.Empty(await _repository.GetServerPerformanceAsync(server, Now.AddDays(-30)));
+ Assert.Empty(await _repository.GetServerDiskHistoryAsync(server, Now.AddDays(-60)));
+ Assert.Equal(3, (await _repository.GetSyntheticCheckResultsAsync(check)).Count);
+ Assert.Empty(await _repository.GetServerFindingsAsync(FindingScope.Server, server));
+ }
+}