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 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; }
|
||||||
|
|
|
||||||
|
|
@ -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>("""
|
||||||
|
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)
|
INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt)
|
||||||
OUTPUT INSERTED.RequestId
|
|
||||||
VALUES (@kind, @targetId, 'Pending', @by, @now);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,17 +227,29 @@ 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)
|
||||||
{
|
{
|
||||||
|
var json = JsonSerializer.Serialize(kept, Json);
|
||||||
|
if (json.Length <= MaxDetailsJson)
|
||||||
|
{
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
kept.RemoveAt(kept.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }), Json);
|
/// <summary>The size of insight.SyntheticCheckResults.DetailsJson.</summary>
|
||||||
return json.Length <= 4000 ? json : null;
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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("""
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue