diff --git a/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs b/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs
index b03a29d..6b3f2e0 100644
--- a/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs
+++ b/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs
@@ -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; }
+
+ /// The Windows server or synthetic check a ServerTest or SyntheticCheckRun is about (1.5).
+ public int? TargetId { get; init; }
+
public string? Parameters { get; init; }
public string Status { get; init; } = AgentRequestStatus.Pending;
public string? RequestedBy { get; init; }
diff --git a/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs b/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs
index 4a71c9b..286b2e2 100644
--- a/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs
+++ b/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs
@@ -35,18 +35,33 @@ public sealed record StoredServerFinding
public DateTime FirstSeenAt { get; init; }
public DateTime LastSeenAt { get; init; }
public DateTime? ResolvedAt { get; init; }
+
+ /// The server or check has been removed: the finding is history, not something to act on (maintenance resolves it).
+ public bool ScopeRemoved { get; init; }
+
+ /// The server or check is paused: its findings are not counted as active on the overviews.
+ public bool ScopePaused { get; init; }
+
+ /// The server or check is looked at now (not paused, not removed).
+ 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
""";
+ /// The finding's server or check is looked at (exists, not removed; paused only counts where noted).
+ 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);
}
- /// Server and check findings: active, resolved or all; for one server or check, or all of them.
+ ///
+ /// 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.
+ ///
public async Task> 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($"""
- 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();
}
- /// Active findings per server or check and severity, for overviews.
+ /// Active findings per server or check and severity, for overviews: only servers and checks looked at now (not paused or removed).
public async Task>> 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)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 ---------------------------------------------------------------------
- /// Queues a server test or a check run for the agent (SQLInsight Web: INSERT only).
+ ///
+ /// 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 ().
+ ///
public async Task 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("""
- 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);
}
diff --git a/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs b/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs
index 0f98f13..45723f3 100644
--- a/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs
+++ b/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs
@@ -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($"""
- 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($"""
- 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;
}
+ ///
+ /// Removes a check (RemovedAt; its results and findings follow retention). The sealed SQL password and the last
+ /// message (which may hold a login or an error text) go at once: a removed check needs neither.
+ ///
public async Task RemoveSyntheticCheckAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
- return await connection.ExecuteAsync(
- "UPDATE insight.SyntheticChecks SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by 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;
}
/// The latest runs of a check, newest first.
@@ -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);
}
- /// At most 20 details of at most 400 characters, so the JSON always fits (never cut in the middle).
- private static string? SerializeDetails(IReadOnlyList 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.
+ ///
+ internal static string? SerializeDetails(IReadOnlyList 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;
}
+ /// The size of insight.SyntheticCheckResults.DetailsJson.
+ internal const int MaxDetailsJson = 4000;
+
/// The agent could (null) or could not open a check's stored password.
public async Task SetSyntheticCheckSecretErrorAsync(int id, string? error, CancellationToken cancellationToken = default)
{
diff --git a/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs b/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs
index 51f1900..2333114 100644
--- a/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs
+++ b/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs
@@ -57,6 +57,19 @@ public sealed record NewWindowsServer(string Name, string Host, string Role, IRe
/// A server's CPU and memory over time.
public sealed record StoredServerPerformance(DateTime SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct);
+///
+/// What the overview shows per server: the latest CPU and memory sample (within the last minutes) and the latest disk
+/// collection with its forecasts.
+///
+public sealed record WindowsServerOverview(int ServerId, StoredServerPerformance? Latest, ServerDiskSnapshot? Disks)
+{
+ /// The volume with the least free space (percent).
+ public ServerDisk? WorstDisk => Disks?.Disks.OrderBy(d => d.FreePercent).FirstOrDefault();
+
+ /// The volume that will be full first, when any is filling up.
+ public DiskForecast? SoonestFull => Disks?.Forecasts.Where(f => f.DaysUntilFull is not null).OrderBy(f => f.DaysUntilFull).FirstOrDefault();
+}
+
/// A Windows event group as stored. is sensitive (local UI only).
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();
}
+ ///
+ /// The overview's figures for all servers that are looked at (not paused or removed), in one query: the newest
+ /// sample since and the latest disk collection.
+ ///
+ public async Task> GetWindowsServerOverviewsAsync(DateTimeOffset sampledSince, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ 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(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(string? json)
+ where T : class
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, Json);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
public async Task 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;
}
- /// Stops looking at a server added in SQLInsight Web. A SQL Server host follows its instances; it can be paused.
+ ///
+ /// The synthetic checks of servers just removed (in @removed) 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).
+ ///
+ 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;
+ """;
+
+ ///
+ /// 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.
+ ///
public async Task 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($"""
+ 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("""
diff --git a/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql b/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql
index 09ee7f1..e3c7380 100644
--- a/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql
+++ b/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql
@@ -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;
diff --git a/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs b/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs
index 13f7d9b..226f2fc 100644
--- a/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs
+++ b/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs
@@ -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);
+ }
+
+ /// What a removed check still has stored (the repository does not read removed checks).
+ 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));
+ }
}