- 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>
263 lines
13 KiB
C#
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);
|
|
}
|
|
}
|