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:
parent
53ffc008f0
commit
1c9d24a6a2
6 changed files with 335 additions and 40 deletions
|
|
@ -79,6 +79,10 @@ public sealed record AgentRequestRow
|
|||
public long RequestId { get; init; }
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
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 Status { get; init; } = AgentRequestStatus.Pending;
|
||||
public string? RequestedBy { get; init; }
|
||||
|
|
|
|||
|
|
@ -35,18 +35,33 @@ public sealed record StoredServerFinding
|
|||
public DateTime FirstSeenAt { get; init; }
|
||||
public DateTime LastSeenAt { 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
|
||||
{
|
||||
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 = """
|
||||
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 = """
|
||||
insight.ServerFindings f
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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,
|
||||
int top = 500, CancellationToken cancellationToken = default)
|
||||
int top = 500, int offset = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<ServerFindingRow>($"""
|
||||
SELECT TOP (@top) {ServerFindingColumns}
|
||||
SELECT {ServerFindingColumns}
|
||||
FROM {ServerFindingFrom}
|
||||
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))
|
||||
ORDER BY CASE WHEN f.ResolvedAt IS NULL THEN 0 ELSE 1 END, f.Severity DESC, f.FirstSeenAt, f.FindingId;
|
||||
""", new { scope, scopeId, status = (int)status, top }).ConfigureAwait(false);
|
||||
AND (@status = 2
|
||||
OR (@status = 1 AND f.ResolvedAt IS NOT NULL)
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
var rows = await connection.QueryAsync<(string Scope, int ScopeId, byte Severity, int Count)>($"""
|
||||
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(
|
||||
g => g.Key,
|
||||
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,
|
||||
LastSeenAt = r.LastSeenAt,
|
||||
ResolvedAt = r.ResolvedAt,
|
||||
ScopeRemoved = r.ScopeRemoved,
|
||||
ScopePaused = r.ScopePaused,
|
||||
};
|
||||
|
||||
// ---- 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)
|
||||
{
|
||||
if (!ServerRequestKind.Handles(kind))
|
||||
|
|
@ -234,9 +267,16 @@ public sealed partial class InsightRepository
|
|||
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.ExecuteScalarAsync<long>("""
|
||||
INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt)
|
||||
OUTPUT INSERTED.RequestId
|
||||
VALUES (@kind, @targetId, 'Pending', @by, @now);
|
||||
DECLARE @id bigint = (SELECT TOP (1) RequestId FROM insight.AgentRequests
|
||||
WHERE Status = 'Pending' AND Kind = @kind AND TargetId = @targetId
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ public sealed record StoredSyntheticCheckResult
|
|||
public sealed partial class InsightRepository
|
||||
{
|
||||
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
|
||||
""";
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ public sealed partial class InsightRepository
|
|||
{
|
||||
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
|
||||
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();
|
||||
|
|
@ -128,7 +128,7 @@ public sealed partial class InsightRepository
|
|||
{
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
|
@ -178,12 +178,18 @@ public sealed partial class InsightRepository
|
|||
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 WHERE CheckId = @id AND RemovedAt IS NULL;",
|
||||
new { id, by, now = Utc(now) }).ConfigureAwait(false) > 0;
|
||||
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>
|
||||
|
|
@ -212,7 +218,7 @@ public sealed partial class InsightRepository
|
|||
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;
|
||||
WHERE CheckId = @id AND RemovedAt IS NULL;
|
||||
""", new
|
||||
{
|
||||
id, at = Utc(result.At), result.Success, result.DurationMs, result.Message,
|
||||
|
|
@ -221,18 +227,30 @@ public sealed partial class InsightRepository
|
|||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>At most 20 details of at most 400 characters, so the JSON always fits (never cut in the middle).</summary>
|
||||
private static string? SerializeDetails(IReadOnlyList<SyntheticCheckDetail> details)
|
||||
/// <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)
|
||||
{
|
||||
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 json.Length <= 4000 ? json : null;
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
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>
|
||||
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);
|
||||
|
|
@ -96,6 +109,49 @@ public sealed partial class InsightRepository
|
|||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var removed = await connection.ExecuteAsync("""
|
||||
UPDATE insight.WindowsServers SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by
|
||||
var removed = await connection.ExecuteScalarAsync<int>($"""
|
||||
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';
|
||||
""", 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)
|
||||
{
|
||||
return ConfigurationChangeOutcome.Changed;
|
||||
|
|
@ -169,10 +244,14 @@ public sealed partial class InsightRepository
|
|||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var at = Utc(now);
|
||||
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
|
||||
OUTPUT inserted.ServerId INTO @removed
|
||||
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)
|
||||
{
|
||||
await connection.ExecuteAsync("""
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ CREATE TABLE insight.WindowsServers
|
|||
Source varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Source DEFAULT ('Web')
|
||||
CONSTRAINT CK_WindowsServers_Source CHECK (Source IN ('Web', 'SqlHost')),
|
||||
Paused bit NOT NULL CONSTRAINT DF_WindowsServers_Paused DEFAULT (0),
|
||||
WatchedServices nvarchar(4000) NULL,
|
||||
IgnoredServices nvarchar(4000) NULL,
|
||||
WatchedServices nvarchar(max) NULL,
|
||||
IgnoredServices nvarchar(max) NULL,
|
||||
CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_CreatedAt DEFAULT (SYSUTCDATETIME()),
|
||||
CreatedBy nvarchar(256) NULL,
|
||||
UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_UpdatedAt DEFAULT (SYSUTCDATETIME()),
|
||||
|
|
@ -44,7 +44,7 @@ CREATE TABLE insight.WindowsServers
|
|||
LastBootUpTime datetime2(0) NULL,
|
||||
TotalMemoryMb float 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_Name ON insight.WindowsServers (Name) WHERE RemovedAt IS NULL;
|
||||
|
|
@ -60,6 +60,8 @@ CREATE TABLE insight.ServerPerformance
|
|||
CommitPct real NULL,
|
||||
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
|
||||
(
|
||||
|
|
@ -70,6 +72,7 @@ CREATE TABLE insight.ServerDiskSamples
|
|||
FreeGb float NOT NULL,
|
||||
CONSTRAINT PK_ServerDiskSamples PRIMARY KEY (ServerId, Volume, SampledAt)
|
||||
);
|
||||
CREATE INDEX IX_ServerDiskSamples_SampledAt ON insight.ServerDiskSamples (SampledAt);
|
||||
|
||||
CREATE TABLE insight.ServerEvents
|
||||
(
|
||||
|
|
@ -87,6 +90,7 @@ CREATE TABLE insight.ServerEvents
|
|||
Sample nvarchar(500) NULL
|
||||
);
|
||||
CREATE CLUSTERED INDEX CX_ServerEvents ON insight.ServerEvents (ServerId, LastAt);
|
||||
CREATE INDEX IX_ServerEvents_LastAt ON insight.ServerEvents (LastAt);
|
||||
|
||||
CREATE TABLE insight.ServerSnapshots
|
||||
(
|
||||
|
|
@ -103,7 +107,7 @@ CREATE TABLE insight.SyntheticChecks
|
|||
Name nvarchar(128) NOT NULL,
|
||||
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),
|
||||
SettingsJson nvarchar(4000) NOT NULL,
|
||||
SettingsJson nvarchar(max) NOT NULL,
|
||||
SecretCipher varbinary(2048) NULL,
|
||||
SecretKeyId varchar(64) NULL,
|
||||
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)
|
||||
);
|
||||
CREATE CLUSTERED INDEX CX_SyntheticCheckResults ON insight.SyntheticCheckResults (CheckId, RunAt);
|
||||
CREATE INDEX IX_SyntheticCheckResults_RunAt ON insight.SyntheticCheckResults (RunAt);
|
||||
|
||||
CREATE TABLE insight.ServerFindings
|
||||
(
|
||||
|
|
@ -186,11 +191,12 @@ CREATE INDEX IX_AgentRequests_Target ON insight.AgentRequests (Kind, TargetId, R
|
|||
GO
|
||||
|
||||
/* 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
|
||||
SQLInsight:Web:AdminGroups. */
|
||||
GRANT INSERT ON insight.WindowsServers 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 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;
|
||||
|
|
|
|||
|
|
@ -249,4 +249,152 @@ public class ServerRepositoryTests(SqlServerFixture sql) : IAsyncLifetime
|
|||
Assert.Equal(3, (await _repository.GetSyntheticCheckResultsAsync(check)).Count);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue