Repository: Windows servers, samples, events, synthetic checks and their findings (0018)

Migration 0018_windows_servers_synthetic_checks:
- insight.WindowsServers (SQL Server hosts kept in step by the agent, servers added
  in SQLInsight Web), ServerPerformance (per minute, one row per sample),
  ServerDiskSamples (forecast history), ServerEvents (grouped like ErrorLogEvents),
  ServerSnapshots (latest per area).
- insight.SyntheticChecks (SQL login password sealed for the agent, as in 1.3) and
  SyntheticCheckResults.
- insight.ServerFindings and ServerRuleStates, scoped to a server or a check.
- AgentRequests.TargetId and the kinds ServerTest and SyntheticCheckRun.
- SQLInsight Web gets INSERT and column UPDATE only; host, name and kind are fixed,
  nothing the agent records can be written, no DELETE.

Findings are opened, updated and resolved like instance findings; a round where a
rule is not measurable keeps what was found open. Retention for samples, disk
history, events, check results and resolved findings (RunServerMaintenanceAsync).

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 13:04:06 +02:00
parent ba7c751b5c
commit 25ecdbc3e7
5 changed files with 1361 additions and 0 deletions

View file

@ -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;
/// <summary>What a server finding is about: a Windows server or a synthetic check (insight.ServerFindings.Scope).</summary>
public static class FindingScope
{
public const string Server = "Server";
public const string Check = "Check";
}
/// <summary>A finding about a Windows server or a synthetic check, as stored.</summary>
public sealed record StoredServerFinding
{
public long FindingId { get; init; }
public string Scope { get; init; } = FindingScope.Server;
public int ScopeId { get; init; }
/// <summary>The server's or the check's name.</summary>
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> Evidence { get; init; } = [];
public IReadOnlyList<Reference> 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
""";
/// <summary>
/// Stores the rule outcomes of one server or check and opens, updates and resolves its findings, like
/// <see cref="SaveRuleResultsAsync"/> for instances. Returns what changed, for notifications.
/// </summary>
public async Task<FindingChanges> SaveScopedRuleResultsAsync(string scope, int scopeId, IReadOnlyList<RuleResult> 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<FindingChanges> SaveScopedRuleResultsOnceAsync(string scope, int scopeId, IReadOnlyList<RuleResult> results, DateTimeOffset evaluatedAt,
CancellationToken cancellationToken)
{
var now = Utc(evaluatedAt);
var opened = new List<(long, Finding)>();
var resolved = new List<ResolvedFinding>();
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<ResolvedFinding>("""
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<long?>("""
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);
}
/// <summary>Server and check findings: active, resolved or all; for one server or check, or all of them.</summary>
public async Task<IReadOnlyList<StoredServerFinding>> 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<ServerFindingRow>($"""
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<StoredServerFinding?> GetServerFindingAsync(long id, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<ServerFindingRow>(
$"SELECT {ServerFindingColumns} FROM {ServerFindingFrom} WHERE f.FindingId = @id;", new { id }).ConfigureAwait(false);
return row is null ? null : Map(row);
}
/// <summary>The earlier occurrences of the same finding (same server or check, rule and subject).</summary>
public async Task<IReadOnlyList<StoredServerFinding>> GetServerFindingHistoryAsync(StoredServerFinding finding, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(finding);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ServerFindingRow>($"""
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<IReadOnlyList<StoredRuleState>> 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();
}
/// <summary>Active findings per server or check and severity, for overviews.</summary>
public async Task<IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>>> 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<Severity, int>)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<List<Evidence>>(r.EvidenceJson, Json) ?? [],
References = JsonSerializer.Deserialize<List<Reference>>(r.ReferencesJson, Json) ?? [],
FirstSeenAt = r.FirstSeenAt,
LastSeenAt = r.LastSeenAt,
ResolvedAt = r.ResolvedAt,
};
// ---- Requests about servers and checks ---------------------------------------------------------------------
/// <summary>Queues a server test or a check run for the agent (SQLInsight Web: INSERT only).</summary>
public async Task<long> 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<long>("""
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);
}
/// <summary>The newest request of a kind about a server or check.</summary>
public async Task<AgentRequestRow?> GetLatestServerRequestAsync(string kind, int targetId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<AgentRequestRow>(
"SELECT TOP 1 * FROM insight.AgentRequests WHERE Kind = @kind AND TargetId = @targetId ORDER BY RequestId DESC;", new { kind, targetId }).ConfigureAwait(false);
}
/// <summary>The server or check a request is about (insight.AgentRequests.TargetId).</summary>
public async Task<int?> GetRequestTargetAsync(long requestId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<int?>("SELECT TargetId FROM insight.AgentRequests WHERE RequestId = @requestId;", new { requestId }).ConfigureAwait(false);
}
}

View file

@ -0,0 +1,245 @@
using System.Text.Json;
using Dapper;
using SQLInsight.Core.SyntheticChecks;
namespace SQLInsight.Repository;
/// <summary>A synthetic check (insight.SyntheticChecks).</summary>
public sealed record SyntheticCheckRow
{
public int CheckId { get; init; }
public string Name { get; init; } = string.Empty;
public string Kind { get; init; } = SyntheticCheckKind.Tcp;
/// <summary>The Windows server the check belongs to (and whose service it looks at), if any.</summary>
public int? ServerId { get; init; }
public string? ServerName { get; init; }
public string SettingsJson { get; init; } = "{}";
/// <summary>The sealed SQL login password. Only the agent can open it; never shown or returned.</summary>
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; }
/// <summary>The agent could not open the stored password (Danish).</summary>
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 };
}
/// <summary>A new or changed check from SQLInsight Web. A null <see cref="SecretCipher"/> on a change keeps the stored password.</summary>
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);
/// <summary>One stored run of a check.</summary>
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<SyntheticCheckDetail> Details
{
get
{
if (string.IsNullOrWhiteSpace(DetailsJson))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<SyntheticCheckDetail>>(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
""";
/// <summary>The checks (not removed), by name.</summary>
public async Task<IReadOnlyList<SyntheticCheckRow>> GetSyntheticChecksAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SyntheticCheckRow>($"""
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<SyntheticCheckRow?> GetSyntheticCheckAsync(int id, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SyntheticCheckRow>($"""
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);
}
/// <summary>Adds a check (SQLInsight Web: INSERT only). A name in use throws SqlException 2601.</summary>
public async Task<int> 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<int>("""
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);
}
/// <summary>Changes a check (not its kind). A name in use throws SqlException 2601.</summary>
public async Task<bool> 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<bool> 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<bool> 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;
}
/// <summary>The latest runs of a check, newest first.</summary>
public async Task<IReadOnlyList<StoredSyntheticCheckResult>> 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<StoredSyntheticCheckResult>("""
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 ----------------------------------------------------------------------------------------------
/// <summary>Stores a run and the check's latest state (and how many times in a row it has failed).</summary>
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);
}
/// <summary>At most 20 details of at most 400 characters, so the JSON always fits (never cut in the middle).</summary>
private static string? SerializeDetails(IReadOnlyList<SyntheticCheckDetail> 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;
}
/// <summary>The agent could (null) or could not open a check's stored password.</summary>
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);
}
}

View file

@ -0,0 +1,411 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using SQLInsight.Core.Servers;
namespace SQLInsight.Repository;
/// <summary>A Windows server the agent looks at (insight.WindowsServers).</summary>
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; }
/// <summary>Why the agent could not reach the server last time (Danish), when that is newer than the last contact.</summary>
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<string> Watched => InsightRepository.ReadList(WatchedServices);
public IReadOnlyList<string> Ignored => InsightRepository.ReadList(IgnoredServices);
/// <summary>Which areas the agent could read at the last collection, and why not.</summary>
public IReadOnlyList<ServerAreaStatus> Areas =>
string.IsNullOrWhiteSpace(AreasJson) ? [] : JsonSerializer.Deserialize<List<ServerAreaStatus>>(AreasJson, InsightRepository.Json) ?? [];
/// <summary>The agent reached the server at its last attempt.</summary>
public bool Reachable => LastContactAt is { } contact && (LastErrorAt is null || LastErrorAt <= contact);
public ServerIdentity ToIdentity() => new(ServerId, Name, Host, Role) { WatchedServices = Watched, IgnoredServices = Ignored };
}
/// <summary>A new Windows server from SQLInsight Web.</summary>
public sealed record NewWindowsServer(string Name, string Host, string Role, IReadOnlyList<string> WatchedServices, IReadOnlyList<string> IgnoredServices, bool Paused = false);
/// <summary>A server's CPU and memory over time.</summary>
public sealed record StoredServerPerformance(DateTime SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct);
/// <summary>A Windows event group as stored. <see cref="Sample"/> is sensitive (local UI only).</summary>
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<string> ReadList(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(json, Json) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static string? WriteList(IReadOnlyList<string>? list) => list is null || list.Count == 0 ? null : JsonSerializer.Serialize(list, Json);
/// <summary>The Windows servers (not removed), by name.</summary>
public async Task<IReadOnlyList<WindowsServerRow>> GetWindowsServersAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<WindowsServerRow>(
$"SELECT {ServerColumns} FROM insight.WindowsServers WHERE RemovedAt IS NULL ORDER BY Name;").ConfigureAwait(false);
return rows.ToList();
}
public async Task<WindowsServerRow?> GetWindowsServerAsync(int id, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<WindowsServerRow>(
$"SELECT {ServerColumns} FROM insight.WindowsServers WHERE ServerId = @id AND RemovedAt IS NULL;", new { id }).ConfigureAwait(false);
}
/// <summary>Adds a server (SQLInsight Web: INSERT only). A name or host in use throws SqlException 2601.</summary>
public async Task<int> 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<int>("""
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);
}
/// <summary>Role and the services to watch and ignore. Allowed for SQL Server hosts too.</summary>
public async Task<bool> UpdateWindowsServerAsync(int id, string role, IReadOnlyList<string> watched, IReadOnlyList<string> 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<bool> 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;
}
/// <summary>Stops looking at a server added in SQLInsight Web. A SQL Server host follows its instances; it can be paused.</summary>
public async Task<ConfigurationChangeOutcome> 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 ----------------------------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Records a successful contact: what the server is, and which areas could be read.</summary>
public async Task RecordServerContactAsync(int id, ServerOsInfo os, string? protocol, IReadOnlyList<ServerAreaStatus>? 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);
}
/// <summary>Which areas could be read (and why not), after any area's collection.</summary>
public async Task RecordServerAreasAsync(int id, IReadOnlyList<ServerAreaStatus> 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);
}
/// <summary>The samples since <paramref name="from"/>, oldest first: the agent's window after a restart, and the charts.</summary>
public async Task<IReadOnlyList<StoredServerPerformance>> GetServerPerformanceAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredServerPerformance>("""
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<ServerDisk> 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);
}
/// <summary>Free space per volume since <paramref name="from"/>, averaged per hour: what the forecast is computed from.</summary>
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DiskHistoryPoint>>> 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<DiskHistoryPoint>)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];
/// <summary>Event groups last seen in [from, to], newest first.</summary>
public async Task<IReadOnlyList<StoredServerEvent>> 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<StoredServerEvent>("""
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();
}
/// <summary>The latest collection of an area, as JSON.</summary>
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<T?> GetServerSnapshotAsync<T>(int id, string area, CancellationToken cancellationToken = default)
where T : class
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var json = await connection.QuerySingleOrDefaultAsync<string>(
"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<T>(json, Json);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Deletes server samples, events and check results past retention, and resolved server findings, in small batches.
/// Called hourly by the agent.
/// </summary>
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);
}
}
/// <summary>How long Windows server data is kept (SQLInsight:Servers and SQLInsight:SyntheticChecks).</summary>
public sealed record ServerRetention
{
/// <summary>Per-minute CPU and memory.</summary>
public int SampleDays { get; init; } = 14;
/// <summary>Free space per volume: must cover the forecast's history.</summary>
public int DiskHistoryDays { get; init; } = 35;
public int EventDays { get; init; } = 90;
public int CheckResultDays { get; init; } = 14;
public int ResolvedFindingDays { get; init; } = 365;
}

View file

@ -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;

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
[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<InsightRepository> 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<SqlException>(() => web.RecordServerContactAsync(server, new ServerOsInfo { Caption = "x" }, "DCOM", null, Now));
await Assert.ThrowsAsync<SqlException>(() => web.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now, 1, 1, 1, 1, 1)));
await Assert.ThrowsAsync<SqlException>(() => web.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now, Success = true, Message = "ok" }));
await Assert.ThrowsAsync<SqlException>(() => web.RecordServerAreasAsync(server, []));
await Assert.ThrowsAsync<SqlException>(() => web.SaveServerSnapshotAsync(server, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = Now }, Now));
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"UPDATE insight.WindowsServers SET Host = N'evil' WHERE ServerId = {server}"));
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"UPDATE insight.SyntheticChecks SET Kind = 'Sql' WHERE CheckId = {check}"));
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"DELETE FROM insight.SyntheticChecks WHERE CheckId = {check}"));
await Assert.ThrowsAsync<SqlException>(() => 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<SqlException>(() => _repository.AddWindowsServerAsync(new NewWindowsServer("Fil", "FIL01", ServerRoles.File, [], []), null, Now));
var name = await Assert.ThrowsAsync<SqlException>(() => _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<ServerServiceSnapshot>(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));
}
}