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