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, 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 """; /// 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 AND w.RemovedAt IS NULL 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 AND w.RemovedAt IS NULL 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; } /// /// 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. /// 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, SecretCipher = NULL, SecretKeyId = NULL, SecretError = NULL, LastMessage = NULL 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 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); } /// /// 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. /// internal static string? SerializeDetails(IReadOnlyList 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; } /// The size of insight.SyntheticCheckResults.DetailsJson. internal const int MaxDetailsJson = 4000; /// 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); } }