using System.Text.Json;
using Dapper;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Repository;
public sealed record InstanceOverview
{
public int InstanceId { get; init; }
public string Name { get; init; } = string.Empty;
public string? ServerName { get; init; }
public string? ProductVersion { get; init; }
public string? ProductLevel { get; init; }
public string? ProductUpdateLevel { get; init; }
public string? Edition { get; init; }
public int? EngineEdition { get; init; }
public string? ConnectionProvider { get; init; }
public bool UsedLegacyFallback { get; init; }
public DateTime? LastSeenAt { get; init; }
public DateTime? LastErrorAt { get; init; }
public string? LastError { get; init; }
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Info { get; init; }
public int NotMeasurable { get; init; }
/// The most recent contact failed.
public bool IsFailing => LastErrorAt is { } e && (LastSeenAt is null || e > LastSeenAt);
public Severity? WorstSeverity =>
Critical > 0 ? Severity.Critical : High > 0 ? Severity.High : Medium > 0 ? Severity.Medium : Low > 0 ? Severity.Low : Info > 0 ? Severity.Info : null;
}
public sealed record StoredFinding
{
public long FindingId { get; init; }
public int InstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public string RuleId { get; init; } = string.Empty;
public string Subject { get; init; } = string.Empty;
public Severity Severity { get; init; }
public string Title { get; init; } = string.Empty;
public string Explanation { get; init; } = string.Empty;
public string Recommendation { get; init; } = string.Empty;
public IReadOnlyList Evidence { get; init; } = [];
public IReadOnlyList References { get; init; } = [];
public string? FixScript { get; init; }
public DateTime FirstSeenAt { get; init; }
public DateTime LastSeenAt { get; init; }
public DateTime? ResolvedAt { get; init; }
}
public sealed record StoredRuleState(string RuleId, string RuleTitle, RuleOutcome Outcome, string? Reason, DateTime EvaluatedAt);
public sealed record MetricPoint(DateTime At, double Value);
public sealed record WaitTotal(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs)
{
public WaitCategory Category => WaitCatalog.Categorize(WaitType);
}
public sealed record FileLatency(string DatabaseName, int FileId, string FileType, long Reads, long ReadStallMs, long Writes, long WriteStallMs)
{
public double? AverageReadMs => Reads == 0 ? null : (double)ReadStallMs / Reads;
public double? AverageWriteMs => Writes == 0 ? null : (double)WriteStallMs / Writes;
}
public sealed record StoredDeadlock(long DeadlockId, DateTime OccurredAt, IReadOnlyList Processes, string GraphXml);
public sealed record StoredBlocking(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, IReadOnlyList Sessions);
public sealed partial class InsightRepository
{
public async Task> GetInstancesAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync("""
SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.EngineEdition, i.ConnectionProvider,
i.UsedLegacyFallback, i.LastSeenAt, i.LastErrorAt, i.LastError,
ISNULL(f.Critical, 0) AS Critical, ISNULL(f.High, 0) AS High, ISNULL(f.Medium, 0) AS Medium, ISNULL(f.Low, 0) AS Low, ISNULL(f.Info, 0) AS Info,
ISNULL(r.NotMeasurable, 0) AS NotMeasurable
FROM insight.Instances i
OUTER APPLY (SELECT SUM(CASE WHEN Severity = 4 THEN 1 ELSE 0 END) AS Critical, SUM(CASE WHEN Severity = 3 THEN 1 ELSE 0 END) AS High,
SUM(CASE WHEN Severity = 2 THEN 1 ELSE 0 END) AS Medium, SUM(CASE WHEN Severity = 1 THEN 1 ELSE 0 END) AS Low,
SUM(CASE WHEN Severity = 0 THEN 1 ELSE 0 END) AS Info
FROM insight.Findings WHERE InstanceId = i.InstanceId AND ResolvedAt IS NULL) f
OUTER APPLY (SELECT COUNT(*) AS NotMeasurable FROM insight.RuleStates WHERE InstanceId = i.InstanceId AND Outcome IN (2, 3)) r
ORDER BY i.Name;
""").ConfigureAwait(false);
return rows.ToList();
}
public async Task GetInstanceAsync(string name, CancellationToken cancellationToken = default) =>
(await GetInstancesAsync(cancellationToken).ConfigureAwait(false)).FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
private const string FindingColumns = """
f.FindingId, f.InstanceId, i.Name AS InstanceName, f.RuleId, f.Subject, f.Severity, f.Title, f.Explanation, f.Recommendation,
f.EvidenceJson, f.ReferencesJson, f.FixScript, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt
""";
public async Task> GetActiveFindingsAsync(int? instanceId = null, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync($"""
SELECT {FindingColumns}
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
WHERE f.ResolvedAt IS NULL AND (@instanceId IS NULL OR f.InstanceId = @instanceId)
ORDER BY f.Severity DESC, f.FirstSeenAt;
""", new { instanceId }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
public async Task> GetRecentlyResolvedAsync(int instanceId, int days, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync($"""
SELECT TOP (50) {FindingColumns}
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
WHERE f.InstanceId = @instanceId AND f.ResolvedAt >= DATEADD(day, -@days, SYSUTCDATETIME())
ORDER BY f.ResolvedAt DESC;
""", new { instanceId, days }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
public async Task GetFindingAsync(long findingId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync($"""
SELECT {FindingColumns}
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
WHERE f.FindingId = @findingId;
""", new { findingId }).ConfigureAwait(false);
return row is null ? null : Map(row);
}
public async Task> GetRuleStatesAsync(int instanceId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string RuleId, string RuleTitle, byte Outcome, string? Reason, DateTime EvaluatedAt)>(
"SELECT RuleId, RuleTitle, Outcome, Reason, EvaluatedAt FROM insight.RuleStates WHERE InstanceId = @instanceId ORDER BY RuleId;",
new { instanceId }).ConfigureAwait(false);
return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList();
}
/// A metric over time: raw samples within raw retention, hourly averages beyond.
public async Task> GetMetricAsync(int instanceId, string metric, DateTimeOffset from, int rawDays = 14, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var useHourly = from.UtcDateTime < DateTime.UtcNow.AddDays(-rawDays) || DateTime.UtcNow - from.UtcDateTime > TimeSpan.FromDays(2);
var sql = useHourly
? "SELECT [Hour] AS At, AvgValue AS Value FROM insight.MetricHourly WHERE InstanceId = @instanceId AND Metric = @metric AND [Hour] >= @from ORDER BY [Hour];"
: "SELECT SampledAt AS At, Value FROM insight.MetricSamples WHERE InstanceId = @instanceId AND Metric = @metric AND SampledAt >= @from ORDER BY SampledAt;";
var rows = await connection.QueryAsync(sql, new { instanceId, metric, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
public async Task> GetTopWaitsAsync(int instanceId, DateTimeOffset from, int top = 10, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync("""
SELECT TOP (@top) WaitType, SUM(WaitingTasks) AS WaitingTasks, SUM(WaitTimeMs) AS WaitTimeMs, SUM(SignalWaitTimeMs) AS SignalWaitTimeMs
FROM insight.WaitSamples WHERE InstanceId = @instanceId AND SampledAt >= @from
GROUP BY WaitType ORDER BY SUM(WaitTimeMs) DESC;
""", new { instanceId, from = from.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
public async Task> GetFileLatencyAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync("""
SELECT DatabaseName, FileId, MAX(FileType) AS FileType, SUM(Reads) AS Reads, SUM(ReadStallMs) AS ReadStallMs, SUM(Writes) AS Writes, SUM(WriteStallMs) AS WriteStallMs
FROM insight.FileIoSamples WHERE InstanceId = @instanceId AND SampledAt >= @from
GROUP BY DatabaseName, FileId
ORDER BY SUM(ReadStallMs) + SUM(WriteStallMs) DESC;
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.ToList();
}
public async Task> GetDeadlocksAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)>("""
SELECT TOP (200) DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks
WHERE InstanceId = @instanceId AND OccurredAt >= @from ORDER BY OccurredAt DESC;
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.Select(r => new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize>(r.ProcessesJson, Json) ?? [], r.GraphXml)).ToList();
}
public async Task GetDeadlockAsync(long deadlockId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var row = await connection.QuerySingleOrDefaultAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)?>(
"SELECT DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks WHERE DeadlockId = @deadlockId;", new { deadlockId }).ConfigureAwait(false);
return row is { } r ? new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize>(r.ProcessesJson, Json) ?? [], r.GraphXml) : null;
}
public async Task> GetBlockingAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, string SessionsJson)>("""
SELECT TOP (200) SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson FROM insight.BlockingEvents
WHERE InstanceId = @instanceId AND SampledAt >= @from ORDER BY SampledAt DESC;
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs,
JsonSerializer.Deserialize>(r.SessionsJson, Json) ?? [])).ToList();
}
public async Task GetSnapshotAsync(int instanceId, string kind, CancellationToken cancellationToken = default)
where T : class
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var json = await connection.QuerySingleOrDefaultAsync(
"SELECT Json FROM insight.Snapshots WHERE InstanceId = @instanceId AND Kind = @kind;", new { instanceId, kind }).ConfigureAwait(false);
return json is null ? null : JsonSerializer.Deserialize(json, Json);
}
private static StoredFinding Map(FindingRow r) => new()
{
FindingId = r.FindingId,
InstanceId = r.InstanceId,
InstanceName = r.InstanceName,
RuleId = r.RuleId,
Subject = r.Subject,
Severity = (Severity)r.Severity,
Title = r.Title,
Explanation = r.Explanation,
Recommendation = r.Recommendation,
Evidence = JsonSerializer.Deserialize>(r.EvidenceJson, Json) ?? [],
References = JsonSerializer.Deserialize>(r.ReferencesJson, Json) ?? [],
FixScript = r.FixScript,
FirstSeenAt = r.FirstSeenAt,
LastSeenAt = r.LastSeenAt,
ResolvedAt = r.ResolvedAt,
};
private sealed record FindingRow
{
public long FindingId { get; init; }
public int InstanceId { get; init; }
public string InstanceName { get; init; } = string.Empty;
public string RuleId { get; init; } = string.Empty;
public string Subject { get; init; } = string.Empty;
public byte Severity { get; init; }
public string Title { get; init; } = string.Empty;
public string Explanation { get; init; } = string.Empty;
public string Recommendation { get; init; } = string.Empty;
public string EvidenceJson { get; init; } = "[]";
public string ReferencesJson { get; init; } = "[]";
public string? FixScript { get; init; }
public DateTime FirstSeenAt { get; init; }
public DateTime LastSeenAt { get; init; }
public DateTime? ResolvedAt { get; init; }
}
}
public sealed partial class InsightRepository
{
/// Hourly averages for the given metrics over the last days, for baselines.
public async Task> GetHourlyHistoryAsync(int instanceId, IReadOnlyCollection metrics, DateTimeOffset now, int days = 28, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Metric, DateTime Hour, double Value)>("""
SELECT Metric, [Hour], AvgValue FROM insight.MetricHourly
WHERE InstanceId = @instanceId AND [Hour] >= @from AND Metric IN (SELECT value FROM OPENJSON(@metrics));
""", new { instanceId, from = now.UtcDateTime.AddDays(-days), metrics = JsonSerializer.Serialize(metrics, Json) }).ConfigureAwait(false);
return rows.Select(r => new HourlyValue(r.Metric, DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc), r.Value)).ToList();
}
}