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>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 15:52:45 +02:00
parent 53ffc008f0
commit 1c9d24a6a2
6 changed files with 335 additions and 40 deletions

View file

@ -79,6 +79,10 @@ public sealed record AgentRequestRow
public long RequestId { get; init; } public long RequestId { get; init; }
public string Kind { get; init; } = string.Empty; public string Kind { get; init; } = string.Empty;
public int? ConfiguredInstanceId { get; init; } public int? ConfiguredInstanceId { get; init; }
/// <summary>The Windows server or synthetic check a ServerTest or SyntheticCheckRun is about (1.5).</summary>
public int? TargetId { get; init; }
public string? Parameters { get; init; } public string? Parameters { get; init; }
public string Status { get; init; } = AgentRequestStatus.Pending; public string Status { get; init; } = AgentRequestStatus.Pending;
public string? RequestedBy { get; init; } public string? RequestedBy { get; init; }

View file

@ -35,18 +35,33 @@ public sealed record StoredServerFinding
public DateTime FirstSeenAt { get; init; } public DateTime FirstSeenAt { get; init; }
public DateTime LastSeenAt { get; init; } public DateTime LastSeenAt { get; init; }
public DateTime? ResolvedAt { get; init; } public DateTime? ResolvedAt { get; init; }
/// <summary>The server or check has been removed: the finding is history, not something to act on (maintenance resolves it).</summary>
public bool ScopeRemoved { get; init; }
/// <summary>The server or check is paused: its findings are not counted as active on the overviews.</summary>
public bool ScopePaused { get; init; }
/// <summary>The server or check is looked at now (not paused, not removed).</summary>
public bool ScopeMonitored => !ScopeRemoved && !ScopePaused;
} }
public sealed partial class InsightRepository 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, 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); string Explanation, string Recommendation, string EvidenceJson, string ReferencesJson, DateTime FirstSeenAt, DateTime LastSeenAt, DateTime? ResolvedAt,
bool ScopeRemoved, bool ScopePaused);
private const string ServerFindingColumns = """ 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.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 f.EvidenceJson, f.ReferencesJson, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt,
CAST(CASE WHEN (w.ServerId IS NULL AND c.CheckId IS NULL) OR w.RemovedAt IS NOT NULL OR c.RemovedAt IS NOT NULL THEN 1 ELSE 0 END AS bit) AS ScopeRemoved,
CAST(COALESCE(w.Paused, c.Paused, 0) AS bit) AS ScopePaused
"""; """;
/// <summary>The finding's server or check is looked at (exists, not removed; paused only counts where noted).</summary>
private const string ScopeInUse = "(w.ServerId IS NOT NULL OR c.CheckId IS NOT NULL) AND w.RemovedAt IS NULL AND c.RemovedAt IS NULL";
private const string ServerFindingFrom = """ private const string ServerFindingFrom = """
insight.ServerFindings f insight.ServerFindings f
LEFT JOIN insight.WindowsServers w ON f.Scope = 'Server' AND w.ServerId = f.ScopeId LEFT JOIN insight.WindowsServers w ON f.Scope = 'Server' AND w.ServerId = f.ScopeId
@ -147,18 +162,26 @@ public sealed partial class InsightRepository
return new FindingChanges(opened, resolved); return new FindingChanges(opened, resolved);
} }
/// <summary>Server and check findings: active, resolved or all; for one server or check, or all of them.</summary> /// <summary>
/// Server and check findings: active, resolved or all; for one server or check, or all of them, a page at a time.
/// Active means open on a server or check that is looked at: never on a removed one, and across all servers and
/// checks not on a paused one either (like the instances' "Alle fund"); a paused server's or check's own list still
/// shows its latest findings. "All" is everything stored.
/// </summary>
public async Task<IReadOnlyList<StoredServerFinding>> GetServerFindingsAsync(string? scope = null, int? scopeId = null, FindingStatus status = FindingStatus.Active, public async Task<IReadOnlyList<StoredServerFinding>> GetServerFindingsAsync(string? scope = null, int? scopeId = null, FindingStatus status = FindingStatus.Active,
int top = 500, CancellationToken cancellationToken = default) int top = 500, int offset = 0, CancellationToken cancellationToken = default)
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<ServerFindingRow>($""" var rows = await connection.QueryAsync<ServerFindingRow>($"""
SELECT TOP (@top) {ServerFindingColumns} SELECT {ServerFindingColumns}
FROM {ServerFindingFrom} FROM {ServerFindingFrom}
WHERE (@scope IS NULL OR f.Scope = @scope) AND (@scopeId IS NULL OR f.ScopeId = @scopeId) 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)) AND (@status = 2
ORDER BY CASE WHEN f.ResolvedAt IS NULL THEN 0 ELSE 1 END, f.Severity DESC, f.FirstSeenAt, f.FindingId; OR (@status = 1 AND f.ResolvedAt IS NOT NULL)
""", new { scope, scopeId, status = (int)status, top }).ConfigureAwait(false); OR (@status = 0 AND f.ResolvedAt IS NULL AND {ScopeInUse} AND (@scopeId IS NOT NULL OR COALESCE(w.Paused, c.Paused, 0) = 0)))
ORDER BY CASE WHEN f.ResolvedAt IS NULL THEN 0 ELSE 1 END, f.Severity DESC, f.FirstSeenAt, f.FindingId
OFFSET @offset ROWS FETCH NEXT @top ROWS ONLY;
""", new { scope, scopeId, status = (int)status, top = Math.Max(1, top), offset = Math.Max(0, offset) }).ConfigureAwait(false);
return rows.Select(Map).ToList(); return rows.Select(Map).ToList();
} }
@ -192,12 +215,16 @@ public sealed partial class InsightRepository
return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList(); 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> /// <summary>Active findings per server or check and severity, for overviews: only servers and checks looked at now (not paused or removed).</summary>
public async Task<IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>>> GetServerFindingCountsAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>>> GetServerFindingCountsAsync(CancellationToken cancellationToken = default)
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Scope, int ScopeId, byte Severity, int Count)>( 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); SELECT f.Scope, f.ScopeId, f.Severity, COUNT(*)
FROM {ServerFindingFrom}
WHERE f.ResolvedAt IS NULL AND {ScopeInUse} AND COALESCE(w.Paused, c.Paused, 0) = 0
GROUP BY f.Scope, f.ScopeId, f.Severity;
""").ConfigureAwait(false);
return rows.GroupBy(r => (r.Scope, r.ScopeId)).ToDictionary( return rows.GroupBy(r => (r.Scope, r.ScopeId)).ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyDictionary<Severity, int>)g.ToDictionary(r => (Severity)r.Severity, r => r.Count)); g => (IReadOnlyDictionary<Severity, int>)g.ToDictionary(r => (Severity)r.Severity, r => r.Count));
@ -220,11 +247,17 @@ public sealed partial class InsightRepository
FirstSeenAt = r.FirstSeenAt, FirstSeenAt = r.FirstSeenAt,
LastSeenAt = r.LastSeenAt, LastSeenAt = r.LastSeenAt,
ResolvedAt = r.ResolvedAt, ResolvedAt = r.ResolvedAt,
ScopeRemoved = r.ScopeRemoved,
ScopePaused = r.ScopePaused,
}; };
// ---- Requests about servers and checks --------------------------------------------------------------------- // ---- Requests about servers and checks ---------------------------------------------------------------------
/// <summary>Queues a server test or a check run for the agent (SQLInsight Web: INSERT only).</summary> /// <summary>
/// Queues a server test or a check run for the agent (SQLInsight Web: INSERT only). One already waiting for the agent
/// (Pending, same kind and server or check) is returned instead of a new one, so repeated clicks and API calls do not
/// fill the agent's queue, as for the connection tests (<see cref="AddAgentRequestAsync"/>).
/// </summary>
public async Task<long> AddServerRequestAsync(string kind, int targetId, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) public async Task<long> AddServerRequestAsync(string kind, int targetId, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{ {
if (!ServerRequestKind.Handles(kind)) if (!ServerRequestKind.Handles(kind))
@ -234,9 +267,16 @@ public sealed partial class InsightRepository
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<long>(""" return await connection.ExecuteScalarAsync<long>("""
INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt) DECLARE @id bigint = (SELECT TOP (1) RequestId FROM insight.AgentRequests
OUTPUT INSERTED.RequestId WHERE Status = 'Pending' AND Kind = @kind AND TargetId = @targetId
VALUES (@kind, @targetId, 'Pending', @by, @now); ORDER BY RequestId DESC);
IF @id IS NULL
BEGIN
INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt)
VALUES (@kind, @targetId, 'Pending', @by, @now);
SET @id = SCOPE_IDENTITY();
END
SELECT @id;
""", new { kind, targetId, by, now = Utc(now) }).ConfigureAwait(false); """, new { kind, targetId, by, now = Utc(now) }).ConfigureAwait(false);
} }

View file

@ -109,7 +109,7 @@ public sealed record StoredSyntheticCheckResult
public sealed partial class InsightRepository public sealed partial class InsightRepository
{ {
private const string CheckColumns = """ 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.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 c.CreatedAt, c.CreatedBy, c.UpdatedAt, c.UpdatedBy, c.RemovedAt, c.SecretError, c.LastRunAt, c.LastSuccess, c.LastDurationMs, c.LastMessage, c.ConsecutiveFailures
"""; """;
@ -118,7 +118,7 @@ public sealed partial class InsightRepository
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SyntheticCheckRow>($""" var rows = await connection.QueryAsync<SyntheticCheckRow>($"""
SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId 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; WHERE c.RemovedAt IS NULL ORDER BY c.Name;
""").ConfigureAwait(false); """).ConfigureAwait(false);
return rows.ToList(); return rows.ToList();
@ -128,7 +128,7 @@ public sealed partial class InsightRepository
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SyntheticCheckRow>($""" return await connection.QuerySingleOrDefaultAsync<SyntheticCheckRow>($"""
SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId 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; WHERE c.CheckId = @id AND c.RemovedAt IS NULL;
""", new { id }).ConfigureAwait(false); """, new { id }).ConfigureAwait(false);
} }
@ -178,12 +178,18 @@ public sealed partial class InsightRepository
new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0; 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) public async Task<bool> RemoveSyntheticCheckAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteAsync( return await connection.ExecuteAsync("""
"UPDATE insight.SyntheticChecks SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by WHERE CheckId = @id AND RemovedAt IS NULL;", UPDATE insight.SyntheticChecks
new { id, by, now = Utc(now) }).ConfigureAwait(false) > 0; 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> /// <summary>The latest runs of a check, newest first.</summary>
@ -212,7 +218,7 @@ public sealed partial class InsightRepository
UPDATE insight.SyntheticChecks UPDATE insight.SyntheticChecks
SET LastRunAt = @at, LastSuccess = @Success, LastDurationMs = @DurationMs, LastMessage = LEFT(@Message, 1000), SET LastRunAt = @at, LastSuccess = @Success, LastDurationMs = @DurationMs, LastMessage = LEFT(@Message, 1000),
ConsecutiveFailures = CASE WHEN @Success = 1 THEN 0 ELSE ConsecutiveFailures + 1 END ConsecutiveFailures = CASE WHEN @Success = 1 THEN 0 ELSE ConsecutiveFailures + 1 END
WHERE CheckId = @id; WHERE CheckId = @id AND RemovedAt IS NULL;
""", new """, new
{ {
id, at = Utc(result.At), result.Success, result.DurationMs, result.Message, id, at = Utc(result.At), result.Success, result.DurationMs, result.Message,
@ -221,18 +227,30 @@ public sealed partial class InsightRepository
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
/// <summary>At most 20 details of at most 400 characters, so the JSON always fits (never cut in the middle).</summary> /// <summary>
private static string? SerializeDetails(IReadOnlyList<SyntheticCheckDetail> details) /// 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)
{ {
if (details.Count == 0) var kept = details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }).ToList();
while (kept.Count > 0)
{ {
return null; var json = JsonSerializer.Serialize(kept, Json);
if (json.Length <= MaxDetailsJson)
{
return json;
}
kept.RemoveAt(kept.Count - 1);
} }
var json = JsonSerializer.Serialize(details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }), Json); return null;
return json.Length <= 4000 ? json : 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> /// <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) public async Task SetSyntheticCheckSecretErrorAsync(int id, string? error, CancellationToken cancellationToken = default)
{ {

View file

@ -57,6 +57,19 @@ public sealed record NewWindowsServer(string Name, string Host, string Role, IRe
/// <summary>A server's CPU and memory over time.</summary> /// <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); public sealed record StoredServerPerformance(DateTime SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct);
/// <summary>
/// What the overview shows per server: the latest CPU and memory sample (within the last minutes) and the latest disk
/// collection with its forecasts.
/// </summary>
public sealed record WindowsServerOverview(int ServerId, StoredServerPerformance? Latest, ServerDiskSnapshot? Disks)
{
/// <summary>The volume with the least free space (percent).</summary>
public ServerDisk? WorstDisk => Disks?.Disks.OrderBy(d => d.FreePercent).FirstOrDefault();
/// <summary>The volume that will be full first, when any is filling up.</summary>
public DiskForecast? SoonestFull => Disks?.Forecasts.Where(f => f.DaysUntilFull is not null).OrderBy(f => f.DaysUntilFull).FirstOrDefault();
}
/// <summary>A Windows event group as stored. <see cref="Sample"/> is sensitive (local UI only).</summary> /// <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, 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); DateTime FirstAt, DateTime LastAt, string? Sample);
@ -96,6 +109,49 @@ public sealed partial class InsightRepository
return rows.ToList(); return rows.ToList();
} }
/// <summary>
/// The overview's figures for all servers that are looked at (not paused or removed), in one query: the newest
/// sample since <paramref name="sampledSince"/> and the latest disk collection.
/// </summary>
public async Task<IReadOnlyDictionary<int, WindowsServerOverview>> GetWindowsServerOverviewsAsync(DateTimeOffset sampledSince, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<OverviewRow>("""
SELECT w.ServerId, p.SampledAt, p.CpuPercent, p.AvailableMb, p.AvailablePct, p.PagesPerSec, p.CommitPct, s.Json AS DisksJson
FROM insight.WindowsServers w
OUTER APPLY (SELECT TOP (1) 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 = w.ServerId AND SampledAt >= @since ORDER BY SampledAt DESC) p
LEFT JOIN insight.ServerSnapshots s ON s.ServerId = w.ServerId AND s.Area = @area
WHERE w.RemovedAt IS NULL AND w.Paused = 0;
""", new { since = sampledSince.UtcDateTime, area = ServerArea.Disks }).ConfigureAwait(false);
return rows.ToDictionary(r => r.ServerId, r => new WindowsServerOverview(
r.ServerId,
r.SampledAt is { } at ? new StoredServerPerformance(at, r.CpuPercent, r.AvailableMb, r.AvailablePct, r.PagesPerSec, r.CommitPct) : null,
ReadSnapshot<ServerDiskSnapshot>(r.DisksJson)));
}
private sealed record OverviewRow(int ServerId, DateTime? SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct,
string? DisksJson);
private static T? ReadSnapshot<T>(string? json)
where T : class
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<T>(json, Json);
}
catch (JsonException)
{
return null;
}
}
public async Task<WindowsServerRow?> GetWindowsServerAsync(int id, CancellationToken cancellationToken = default) public async Task<WindowsServerRow?> GetWindowsServerAsync(int id, CancellationToken cancellationToken = default)
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
@ -138,14 +194,33 @@ public sealed partial class InsightRepository
new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0; 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> /// <summary>
/// The synthetic checks of servers just removed (in <c>@removed</c>) no longer belong to a server: they keep running,
/// without the server's service (its name is taken out of the settings, as the form and the API require a server for it).
/// </summary>
private const string DetachChecksFromRemovedServers = """
UPDATE c SET ServerId = NULL, SettingsJson = JSON_MODIFY(c.SettingsJson, '$.serviceName', NULL), UpdatedAt = @at, UpdatedBy = @checkBy
FROM insight.SyntheticChecks c JOIN @removed r ON r.ServerId = c.ServerId;
""";
/// <summary>
/// Stops looking at a server added in SQLInsight Web; its synthetic checks are kept without the server (same
/// transaction). 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) public async Task<ConfigurationChangeOutcome> RemoveWindowsServerAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{ {
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var removed = await connection.ExecuteAsync(""" var removed = await connection.ExecuteScalarAsync<int>($"""
UPDATE insight.WindowsServers SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @removed TABLE (ServerId int PRIMARY KEY);
UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = @by, UpdatedAt = @at, UpdatedBy = @by
OUTPUT inserted.ServerId INTO @removed
WHERE ServerId = @id AND RemovedAt IS NULL AND Source = 'Web'; WHERE ServerId = @id AND RemovedAt IS NULL AND Source = 'Web';
""", new { id, by, now = Utc(now) }).ConfigureAwait(false); {DetachChecksFromRemovedServers}
COMMIT;
SELECT COUNT(*) FROM @removed;
""", new { id, by, checkBy = by, at = Utc(now) }).ConfigureAwait(false);
if (removed > 0) if (removed > 0)
{ {
return ConfigurationChangeOutcome.Changed; return ConfigurationChangeOutcome.Changed;
@ -169,10 +244,14 @@ public sealed partial class InsightRepository
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var at = Utc(now); var at = Utc(now);
var normalized = hosts.Select(h => (Host: ServerHost.Normalize(h.Host), h.Name)).DistinctBy(h => h.Host, StringComparer.OrdinalIgnoreCase).ToList(); var normalized = hosts.Select(h => (Host: ServerHost.Normalize(h.Host), h.Name)).DistinctBy(h => h.Host, StringComparer.OrdinalIgnoreCase).ToList();
await connection.ExecuteAsync(""" await connection.ExecuteAsync($"""
DECLARE @removed TABLE (ServerId int PRIMARY KEY);
UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: ingen overvåget instans på serveren', UpdatedAt = @at UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: ingen overvåget instans på serveren', UpdatedAt = @at
OUTPUT inserted.ServerId INTO @removed
WHERE Source = 'SqlHost' AND RemovedAt IS NULL AND Host NOT IN (SELECT value FROM OPENJSON(@hosts)); 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); {DetachChecksFromRemovedServers}
""", new { hosts = JsonSerializer.Serialize(normalized.Select(h => h.Host)), at, checkBy = "SQLInsight Agent (SQL Server-host fjernet)" }, transaction)
.ConfigureAwait(false);
foreach (var (host, name) in normalized) foreach (var (host, name) in normalized)
{ {
await connection.ExecuteAsync(""" await connection.ExecuteAsync("""

View file

@ -26,8 +26,8 @@ CREATE TABLE insight.WindowsServers
Source varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Source DEFAULT ('Web') Source varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Source DEFAULT ('Web')
CONSTRAINT CK_WindowsServers_Source CHECK (Source IN ('Web', 'SqlHost')), CONSTRAINT CK_WindowsServers_Source CHECK (Source IN ('Web', 'SqlHost')),
Paused bit NOT NULL CONSTRAINT DF_WindowsServers_Paused DEFAULT (0), Paused bit NOT NULL CONSTRAINT DF_WindowsServers_Paused DEFAULT (0),
WatchedServices nvarchar(4000) NULL, WatchedServices nvarchar(max) NULL,
IgnoredServices nvarchar(4000) NULL, IgnoredServices nvarchar(max) NULL,
CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_CreatedAt DEFAULT (SYSUTCDATETIME()), CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_CreatedAt DEFAULT (SYSUTCDATETIME()),
CreatedBy nvarchar(256) NULL, CreatedBy nvarchar(256) NULL,
UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_UpdatedAt DEFAULT (SYSUTCDATETIME()), UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_UpdatedAt DEFAULT (SYSUTCDATETIME()),
@ -44,7 +44,7 @@ CREATE TABLE insight.WindowsServers
LastBootUpTime datetime2(0) NULL, LastBootUpTime datetime2(0) NULL,
TotalMemoryMb float NULL, TotalMemoryMb float NULL,
LogicalProcessors int NULL, LogicalProcessors int NULL,
AreasJson nvarchar(4000) NULL AreasJson nvarchar(max) NULL
); );
CREATE UNIQUE INDEX UQ_WindowsServers_Host ON insight.WindowsServers (Host) WHERE RemovedAt IS 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 UNIQUE INDEX UQ_WindowsServers_Name ON insight.WindowsServers (Name) WHERE RemovedAt IS NULL;
@ -60,6 +60,8 @@ CREATE TABLE insight.ServerPerformance
CommitPct real NULL, CommitPct real NULL,
CONSTRAINT PK_ServerPerformance PRIMARY KEY (ServerId, SampledAt) CONSTRAINT PK_ServerPerformance PRIMARY KEY (ServerId, SampledAt)
); );
/* Retention deletes by time across all servers. */
CREATE INDEX IX_ServerPerformance_SampledAt ON insight.ServerPerformance (SampledAt);
CREATE TABLE insight.ServerDiskSamples CREATE TABLE insight.ServerDiskSamples
( (
@ -70,6 +72,7 @@ CREATE TABLE insight.ServerDiskSamples
FreeGb float NOT NULL, FreeGb float NOT NULL,
CONSTRAINT PK_ServerDiskSamples PRIMARY KEY (ServerId, Volume, SampledAt) CONSTRAINT PK_ServerDiskSamples PRIMARY KEY (ServerId, Volume, SampledAt)
); );
CREATE INDEX IX_ServerDiskSamples_SampledAt ON insight.ServerDiskSamples (SampledAt);
CREATE TABLE insight.ServerEvents CREATE TABLE insight.ServerEvents
( (
@ -87,6 +90,7 @@ CREATE TABLE insight.ServerEvents
Sample nvarchar(500) NULL Sample nvarchar(500) NULL
); );
CREATE CLUSTERED INDEX CX_ServerEvents ON insight.ServerEvents (ServerId, LastAt); CREATE CLUSTERED INDEX CX_ServerEvents ON insight.ServerEvents (ServerId, LastAt);
CREATE INDEX IX_ServerEvents_LastAt ON insight.ServerEvents (LastAt);
CREATE TABLE insight.ServerSnapshots CREATE TABLE insight.ServerSnapshots
( (
@ -103,7 +107,7 @@ CREATE TABLE insight.SyntheticChecks
Name nvarchar(128) NOT NULL, Name nvarchar(128) NOT NULL,
Kind varchar(16) NOT NULL CONSTRAINT CK_SyntheticChecks_Kind CHECK (Kind IN ('Tcp', 'Http', 'Sql', 'Dns', 'AxLogon')), 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), ServerId int NULL CONSTRAINT FK_SyntheticChecks_Server REFERENCES insight.WindowsServers (ServerId),
SettingsJson nvarchar(4000) NOT NULL, SettingsJson nvarchar(max) NOT NULL,
SecretCipher varbinary(2048) NULL, SecretCipher varbinary(2048) NULL,
SecretKeyId varchar(64) NULL, SecretKeyId varchar(64) NULL,
IntervalSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Interval DEFAULT (300) IntervalSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Interval DEFAULT (300)
@ -142,6 +146,7 @@ CREATE TABLE insight.SyntheticCheckResults
[Partial] bit NOT NULL CONSTRAINT DF_SyntheticCheckResults_Partial DEFAULT (0) [Partial] bit NOT NULL CONSTRAINT DF_SyntheticCheckResults_Partial DEFAULT (0)
); );
CREATE CLUSTERED INDEX CX_SyntheticCheckResults ON insight.SyntheticCheckResults (CheckId, RunAt); CREATE CLUSTERED INDEX CX_SyntheticCheckResults ON insight.SyntheticCheckResults (CheckId, RunAt);
CREATE INDEX IX_SyntheticCheckResults_RunAt ON insight.SyntheticCheckResults (RunAt);
CREATE TABLE insight.ServerFindings CREATE TABLE insight.ServerFindings
( (
@ -186,11 +191,12 @@ CREATE INDEX IX_AgentRequests_Target ON insight.AgentRequests (Kind, TargetId, R
GO GO
/* SQLInsight Web only has db_datareader. As in 0016 it gets exactly what it needs to change the configuration: add /* 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, servers and checks, change what may be changed, and remove (RemovedAt, no DELETE; a removed check's sealed password
and last message are cleared, and checks of a removed server lose the server). Nothing else 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 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. */ SQLInsight:Web:AdminGroups. */
GRANT INSERT ON insight.WindowsServers TO public; GRANT INSERT ON insight.WindowsServers TO public;
GRANT UPDATE ON insight.WindowsServers (Role, Paused, WatchedServices, IgnoredServices, UpdatedAt, UpdatedBy, RemovedAt, RemovedBy) 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 INSERT ON insight.SyntheticChecks TO public;
GRANT UPDATE ON insight.SyntheticChecks (Name, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused, GRANT UPDATE ON insight.SyntheticChecks (Name, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused,
UpdatedAt, UpdatedBy, RemovedAt, RemovedBy, SecretError) TO public; UpdatedAt, UpdatedBy, RemovedAt, RemovedBy, SecretError, LastMessage) TO public;

View file

@ -249,4 +249,152 @@ public class ServerRepositoryTests(SqlServerFixture sql) : IAsyncLifetime
Assert.Equal(3, (await _repository.GetSyntheticCheckResultsAsync(check)).Count); Assert.Equal(3, (await _repository.GetSyntheticCheckResultsAsync(check)).Count);
Assert.Empty(await _repository.GetServerFindingsAsync(FindingScope.Server, server)); Assert.Empty(await _repository.GetServerFindingsAsync(FindingScope.Server, server));
} }
[Fact]
public async Task A_test_or_run_waiting_for_the_agent_is_reused()
{
var web = await ReadOnlyRepositoryAsync();
var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now);
var other = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS02", "aos02", ServerRoles.Application, [], []), null, Now);
var first = await web.AddServerRequestAsync(ServerRequestKind.ServerTest, server, "anna", Now);
Assert.Equal(first, await web.AddServerRequestAsync(ServerRequestKind.ServerTest, server, "bo", Now));
Assert.NotEqual(first, await web.AddServerRequestAsync(ServerRequestKind.ServerTest, other, "anna", Now));
Assert.NotEqual(first, await web.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, server, "anna", Now));
// Once the agent has taken it, a new one is queued.
var claimed = await _repository.ClaimAgentRequestAsync(Now);
Assert.Equal(first, claimed!.RequestId);
Assert.Equal(server, claimed.TargetId);
Assert.NotEqual(first, await web.AddServerRequestAsync(ServerRequestKind.ServerTest, server, "anna", Now));
}
[Fact]
public async Task Findings_of_paused_and_removed_servers_and_checks_are_not_active()
{
var web = await ReadOnlyRepositoryAsync();
var paused = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, Now);
var removed = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL02", "fil02", ServerRoles.File, [], []), null, Now);
var watched = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL03", "fil03", ServerRoles.File, [], []), null, Now);
var check = await _repository.AddSyntheticCheckAsync(Check("Port"), null, Now);
foreach (var server in new[] { paused, removed, watched })
{
await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\"))], Now);
}
await _repository.SaveScopedRuleResultsAsync(FindingScope.Check, check, [Found("SQLI-SYN-001", Finding("SQLI-SYN-001", "check"))], Now);
await web.SetWindowsServerPausedAsync(paused, true, "anna", Now);
await web.RemoveWindowsServerAsync(removed, "anna", Now);
await web.RemoveSyntheticCheckAsync(check, "anna", Now);
var active = await web.GetServerFindingsAsync();
Assert.Equal([watched], active.Select(f => f.ScopeId).ToList());
Assert.True(active[0].ScopeMonitored);
Assert.Equal([(FindingScope.Server, watched)], (await web.GetServerFindingCountsAsync()).Keys.ToList());
// A paused server's own list keeps its latest findings; a removed one's are history only ("all").
var own = Assert.Single(await web.GetServerFindingsAsync(FindingScope.Server, paused));
Assert.True(own.ScopePaused);
Assert.Empty(await web.GetServerFindingsAsync(FindingScope.Server, removed));
Assert.True(Assert.Single(await web.GetServerFindingsAsync(FindingScope.Server, removed, FindingStatus.All)).ScopeRemoved);
Assert.True((await web.GetServerFindingAsync(Assert.Single(await web.GetServerFindingsAsync(FindingScope.Check, check, FindingStatus.All)).FindingId))!.ScopeRemoved);
Assert.Equal(4, (await web.GetServerFindingsAsync(status: FindingStatus.All)).Count);
}
[Fact]
public async Task Findings_come_a_page_at_a_time()
{
var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, Now);
await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server,
[Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: C:\", Severity.Critical), Finding("SQLI-WIN-005", @"disk: D:\"), Finding("SQLI-WIN-005", @"disk: E:\", Severity.Low))], Now);
var first = await _repository.GetServerFindingsAsync(top: 2);
var rest = await _repository.GetServerFindingsAsync(top: 2, offset: 2);
Assert.Equal([@"disk: C:\", @"disk: D:\"], first.Select(f => f.Subject).ToList());
Assert.Equal(@"disk: E:\", Assert.Single(rest).Subject);
}
[Fact]
public async Task Removing_a_server_keeps_its_checks_without_the_server_and_a_removed_check_forgets_its_password()
{
var web = await ReadOnlyRepositoryAsync();
var server = await web.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), "anna", Now);
var tcp = await web.AddSyntheticCheckAsync(Check("AOS port", server), "anna", Now);
var sqlCheck = await web.AddSyntheticCheckAsync(Check("SQL", server, [1, 2, 3]), "anna", Now);
await _repository.RecordSyntheticCheckResultAsync(sqlCheck, new SyntheticCheckResult { At = Now, Success = false, Message = "Login failed for user 'axapp'." });
await _repository.SetSyntheticCheckSecretErrorAsync(sqlCheck, "Kan ikke åbne");
Assert.Equal(ConfigurationChangeOutcome.Changed, await web.RemoveWindowsServerAsync(server, "anna", Now));
var row = (await web.GetSyntheticCheckAsync(tcp))!;
Assert.Equal((null, null, null), (row.ServerId, row.ServerName, row.Settings.ServiceName));
Assert.Equal("aos01", row.Settings.Host);
Assert.True(await web.RemoveSyntheticCheckAsync(sqlCheck, "anna", Now));
var removed = await RemovedCheckAsync(sqlCheck);
Assert.Equal((null, null, null, null), (removed.SecretCipher, removed.SecretKeyId, removed.SecretError, removed.LastMessage));
// A late result of the agent does not bring the message back.
await _repository.RecordSyntheticCheckResultAsync(sqlCheck, new SyntheticCheckResult { At = Now, Success = false, Message = "Login failed for user 'axapp'." });
Assert.Null((await RemovedCheckAsync(sqlCheck)).LastMessage);
}
[Fact]
public async Task A_sql_host_removed_by_the_agent_leaves_its_checks_without_the_server()
{
await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], Now);
var host = Assert.Single(await _repository.GetWindowsServersAsync()).ServerId;
var check = await _repository.AddSyntheticCheckAsync(Check("SQL01 port", host), null, Now);
await _repository.SyncSqlHostServersAsync([], Now);
var row = (await _repository.GetSyntheticCheckAsync(check))!;
Assert.Null(row.ServerId);
Assert.Null(row.Settings.ServiceName);
}
[Fact]
public async Task Long_details_are_trimmed_to_fit_and_the_overview_reads_all_servers_at_once()
{
var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now);
var idle = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS02", "aos02", ServerRoles.Application, [], []), null, Now);
var check = await _repository.AddSyntheticCheckAsync(Check("Port"), null, Now);
await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult
{
At = Now, Success = false, Message = "x", Details = Enumerable.Range(1, 20).Select(i => new SyntheticCheckDetail($"Felt {i}", new string('æ', 400))).ToList(),
});
var details = Assert.Single(await _repository.GetSyntheticCheckResultsAsync(check)).Details;
Assert.InRange(details.Count, 1, 19);
Assert.Equal("Felt 1", details[0].Name);
await _repository.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now.AddMinutes(-2), 10, 4000, 16384, 1, 1));
await _repository.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now.AddMinutes(-1), 20, 3000, 16384, 1, 1));
await _repository.SaveServerSnapshotAsync(server, ServerArea.Disks, new ServerDiskSnapshot
{
CapturedAt = Now,
Disks = [new ServerDisk(@"C:\", null, "NTFS", 100, 50), new ServerDisk(@"D:\", null, "NTFS", 100, 5)],
Forecasts = [new DiskForecast(@"C:\", 30, 1, 100, 14, 0.9), new DiskForecast(@"D:\", null, 0, 100, 14, 0.1, "fyldes ikke op")],
}, Now);
var overviews = await _repository.GetWindowsServerOverviewsAsync(Now.AddMinutes(-10));
Assert.Equal(20, overviews[server].Latest!.CpuPercent!.Value, 3);
Assert.Equal(@"D:\", overviews[server].WorstDisk!.Volume);
Assert.Equal(@"C:\", overviews[server].SoonestFull!.Volume);
Assert.Null(overviews[idle].Latest);
Assert.Null(overviews[idle].Disks);
}
/// <summary>What a removed check still has stored (the repository does not read removed checks).</summary>
private async Task<(byte[]? SecretCipher, string? SecretKeyId, string? SecretError, string? LastMessage)> RemovedCheckAsync(int id)
{
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT SecretCipher, SecretKeyId, SecretError, LastMessage FROM insight.SyntheticChecks WHERE CheckId = @id;";
command.Parameters.AddWithValue("@id", id);
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
return (reader.IsDBNull(0) ? null : (byte[])reader[0], reader.IsDBNull(1) ? null : reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2),
reader.IsDBNull(3) ? null : reader.GetString(3));
}
} }