sqlinsight-pro/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs
Søren Eggert Lundsteen Olsen 1c9d24a6a2 Review 1.5: repository: reuse waiting agent requests, active findings only on monitored servers and checks, cleanup on removal
- AddServerRequestAsync returns a ServerTest/SyntheticCheckRun already waiting for the agent (Pending, same kind and
  target) instead of queueing another one, like AddAgentRequestAsync; AgentRequestRow carries TargetId.
- Active server findings and the overview counts leave out removed servers and checks, and paused ones across all
  (a paused one's own list keeps its latest findings, as for instances); findings say ScopeRemoved/ScopePaused.
  GetServerFindingsAsync pages with OFFSET/FETCH.
- Removing a server (in the web UI or a SQL Server host by the agent's sync) detaches its synthetic checks in the same
  transaction (ServerId NULL, serviceName out of the settings); check queries only join servers in use.
- Removing a check clears its sealed password, key id, secret error and last message; a late agent result does not
  write to a removed check. (0018: UPDATE on LastMessage for the web login.)
- 0018: settings and service lists as nvarchar(max) (no 500 on long æøå queries); indexes on SampledAt/LastAt/RunAt
  for the retention deletes. Run details that do not fit are trimmed from the end instead of all dropped.
- One query for the overview's latest CPU/memory and disk collection per server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:53:26 +02:00

263 lines
13 KiB
C#

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, w.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 AND w.RemovedAt IS NULL
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 AND w.RemovedAt IS NULL
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;
}
/// <summary>
/// Removes a check (RemovedAt; its results and findings follow retention). The sealed SQL password and the last
/// message (which may hold a login or an error text) go at once: a removed check needs neither.
/// </summary>
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, SecretCipher = NULL, SecretKeyId = NULL, SecretError = NULL, LastMessage = NULL
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 AND RemovedAt IS NULL;
""", 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 fits DetailsJson (never cut in the middle): when the
/// escaped JSON is still too long, the last details are left out, not all of them.
/// </summary>
internal static string? SerializeDetails(IReadOnlyList<SyntheticCheckDetail> details)
{
var kept = details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }).ToList();
while (kept.Count > 0)
{
var json = JsonSerializer.Serialize(kept, Json);
if (json.Length <= MaxDetailsJson)
{
return json;
}
kept.RemoveAt(kept.Count - 1);
}
return null;
}
/// <summary>The size of insight.SyntheticCheckResults.DetailsJson.</summary>
internal const int MaxDetailsJson = 4000;
/// <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);
}
}