When the distribution database could not be read, SQL Server's message (which names the agent's login) was appended to Unavailable. That text is the rules' "not measurable" reason and is returned unredacted by /rule-states and /replication. It is now UnavailableDetail: shown on the local instance page and [udeladt] in the API unless ApiIncludesSensitiveEvidence. The agent list is also capped (TOP 500). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
644 lines
34 KiB
C#
644 lines
34 KiB
C#
using System.Globalization;
|
||
using Microsoft.Extensions.Options;
|
||
using SQLInsight.Core.Data;
|
||
using SQLInsight.Core.Findings;
|
||
using SQLInsight.Reports;
|
||
using SQLInsight.Repository;
|
||
|
||
namespace SQLInsight.Web.Api;
|
||
|
||
/// <summary>
|
||
/// The read-only JSON API for findings, rules, reports and the pages of the web UI: the same data, windows and filters
|
||
/// as the pages, built from the same models. Customer data (statement texts, logins, names) is sensitive and returned
|
||
/// as "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
|
||
/// </summary>
|
||
public static class ApiV1Endpoints
|
||
{
|
||
public const string Redacted = "[udeladt]";
|
||
|
||
private static readonly string[] ReportFormats = ["json", "pdf"];
|
||
|
||
/// <summary>Report types under /api/v1/reports/{type}.</summary>
|
||
public static readonly IReadOnlyList<(string Type, string Title, string Parameters)> ReportTypes =
|
||
[
|
||
("driftsrapport", "Driftsrapport til ledelse og compliance", "days (1–366, standard 30)"),
|
||
("analyse", "Analyse af tidsrum for én instans", "instance (påkrævet), from, to (ISO 8601; standard seneste time), sort (cpu|varighed|laesninger|koersler)"),
|
||
("rettigheder", "Rettighedsrevision", "days (1–366, standard 90), instance (valgfri; standard alle)"),
|
||
];
|
||
|
||
public static void Map(IEndpointRouteBuilder app)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(app);
|
||
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
|
||
|
||
api.MapGet("/findings", SearchFindingsAsync)
|
||
.WithSummary("Fund: aktive, løste eller alle, med filtre og sider / Findings with filters and paging")
|
||
.WithDescription("Returns an array. More pages: the response header X-Continuation holds the value for ?continuation=.");
|
||
api.MapGet("/findings/{id:long}", FindingAsync).WithSummary("Ét fund med forklaring, anbefaling, målinger, fix-script og historik / One finding");
|
||
api.MapGet("/rules", RulesAsync).WithSummary("Regler: indbyggede, community og lokale / Rules");
|
||
api.MapGet("/instances/{name}/rule-states", RuleStatesAsync).WithSummary("Reglernes udfald på en instans / Rule outcomes on an instance");
|
||
api.MapGet("/reports", () => ReportTypes.Select(r => new { r.Type, r.Title, r.Parameters, Formats = ReportFormats }))
|
||
.WithSummary("Rapporttyper og deres parametre / Report types");
|
||
api.MapGet("/reports/{type}", ReportAsync).WithSummary("En rapport som JSON eller PDF (format=json|pdf) / A report as JSON or PDF");
|
||
api.MapGet("/instances/{name}/analysis", AnalysisAsync).WithSummary("Analyse af tidsrum / Time window analysis");
|
||
api.MapGet("/instances/{name}/top-sql", TopSqlAsync).WithSummary("Tungeste sætninger i et tidsrum / Top SQL in a window");
|
||
api.MapGet("/instances/{name}/disks", DisksAsync).WithSummary("Diskhastighed pr. drev med vurdering og tidsserier / Disk speed per drive");
|
||
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
|
||
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
|
||
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
|
||
api.MapGet("/instances/{name}/activity", ActivityAsync).WithSummary("Åbne transaktioner og lange sætninger lige nu / Open transactions and long-running statements");
|
||
api.MapGet("/instances/{name}/tempdb", TempdbAsync).WithSummary("tempdb og version store lige nu / tempdb and the version store");
|
||
api.MapGet("/instances/{name}/replication", ReplicationAsync).WithSummary("Replikering set fra distributøren / Replication from the distributor");
|
||
api.MapGet("/instances/{name}/index-usage", IndexUsageAsync).WithSummary("Indexbrug over tid, på tværs af genstarter / Index usage over time (days)");
|
||
api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)");
|
||
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
|
||
}
|
||
|
||
private sealed class Redactor(bool include)
|
||
{
|
||
public bool Include => include;
|
||
|
||
public string? Value(string? value, bool sensitive = true) => sensitive && !include && value is not null ? Redacted : value;
|
||
}
|
||
|
||
private static Redactor RedactorFor(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
|
||
|
||
private static object FindingSummary(StoredFinding f, Redactor redact) => new
|
||
{
|
||
Id = f.FindingId,
|
||
Instance = f.InstanceName,
|
||
f.RuleId,
|
||
Category = Category(f.RuleId),
|
||
f.Subject,
|
||
Severity = f.Severity.ToString(),
|
||
f.Title,
|
||
f.Explanation,
|
||
f.Recommendation,
|
||
Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive) }),
|
||
f.FirstSeenAt,
|
||
f.LastSeenAt,
|
||
f.ResolvedAt,
|
||
};
|
||
|
||
/// <summary>SEC in SQLI-SEC-001, FT in COM-FT-002.</summary>
|
||
public static string Category(string ruleId)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(ruleId);
|
||
var parts = ruleId.Split('-');
|
||
return parts.Length >= 3 ? parts[1] : ruleId;
|
||
}
|
||
|
||
private static async Task<IResult> SearchFindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
|
||
string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? instance = null, string? database = null,
|
||
string? ruleId = null, Severity? minSeverity = null, string? category = null, string? sort = null, int? limit = null, string? continuation = null)
|
||
{
|
||
var findingStatus = status?.ToLowerInvariant() switch
|
||
{
|
||
null or "" or "active" => (FindingStatus?)FindingStatus.Active,
|
||
"resolved" => FindingStatus.Resolved,
|
||
"all" => FindingStatus.All,
|
||
_ => null,
|
||
};
|
||
var findingSort = sort?.ToLowerInvariant() switch
|
||
{
|
||
null or "" or "severity" => (FindingSort?)FindingSort.Severity,
|
||
"firstseen" => FindingSort.FirstSeen,
|
||
"lastseen" => FindingSort.LastSeen,
|
||
"resolved" => FindingSort.Resolved,
|
||
_ => null,
|
||
};
|
||
if (findingStatus is null || findingSort is null)
|
||
{
|
||
return Results.BadRequest("status skal være active, resolved eller all; sort skal være severity, firstSeen, lastSeen eller resolved.");
|
||
}
|
||
|
||
var offset = 0;
|
||
if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
|
||
{
|
||
return Results.BadRequest("Ugyldig continuation.");
|
||
}
|
||
|
||
var size = Math.Clamp(limit ?? 100, 1, InsightRepository.MaxFindingPage);
|
||
var (findings, more) = await repository.SearchFindingsAsync(new FindingQuery
|
||
{
|
||
Status = findingStatus.Value,
|
||
From = from,
|
||
To = to,
|
||
Instance = instance,
|
||
Database = database,
|
||
RuleId = ruleId,
|
||
MinSeverity = minSeverity,
|
||
Category = category,
|
||
Sort = findingSort.Value,
|
||
Offset = offset,
|
||
Limit = size,
|
||
}, ct).ConfigureAwait(false);
|
||
if (more)
|
||
{
|
||
http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
return Results.Ok(findings.Select(f => FindingSummary(f, redact)));
|
||
}
|
||
|
||
private static async Task<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
|
||
{
|
||
if (await repository.GetFindingAsync(id, ct).ConfigureAwait(false) is not { } f)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
return Results.Ok(new
|
||
{
|
||
Id = f.FindingId,
|
||
Instance = f.InstanceName,
|
||
f.RuleId,
|
||
Category = Category(f.RuleId),
|
||
f.Subject,
|
||
Severity = f.Severity.ToString(),
|
||
f.Title,
|
||
f.Explanation,
|
||
f.Recommendation,
|
||
Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive), e.Sensitive }),
|
||
f.FixScript,
|
||
References = f.References.Select(r => new { r.Title, r.Url }),
|
||
History = new { f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt, Status = f.ResolvedAt is null ? "active" : "resolved" },
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> RulesAsync(InsightRepository repository, CancellationToken ct)
|
||
{
|
||
var builtIn = Rules.BuiltInRules.Create().Select(r => (r.Id, r.Title, Source: "indbygget"));
|
||
var community = Rules.Lifecycle.RulePack.Embedded().CommunityRules.Select(r => (r.Id, r.Title, Source: "community"));
|
||
var seen = (await repository.GetAllRuleStatesAsync(ct).ConfigureAwait(false)).Select(s => s.RuleId);
|
||
var known = builtIn.Concat(community).ToList();
|
||
var titles = (await repository.GetRuleTitlesAsync(ct).ConfigureAwait(false)).ToDictionary(t => t.RuleId, t => t.Title, StringComparer.OrdinalIgnoreCase);
|
||
var local = seen.Distinct(StringComparer.OrdinalIgnoreCase).Where(id => known.All(k => !string.Equals(k.Id, id, StringComparison.OrdinalIgnoreCase)))
|
||
.Select(id => (Id: id, Title: titles.GetValueOrDefault(id) ?? id, Source: id.StartsWith("COM-", StringComparison.Ordinal) ? "community" : "lokal"));
|
||
return Results.Ok(known.Concat(local).OrderBy(r => r.Id, StringComparer.Ordinal)
|
||
.Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source }));
|
||
}
|
||
|
||
private static async Task<IResult> RuleStatesAsync(string name, InsightRepository repository, CancellationToken ct)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
return Results.Ok((await repository.GetRuleStatesAsync(instance.InstanceId, ct).ConfigureAwait(false)).Select(s => new
|
||
{
|
||
s.RuleId,
|
||
s.RuleTitle,
|
||
Outcome = s.Outcome switch
|
||
{
|
||
Rules.Engine.RuleOutcome.Passed => "passed",
|
||
Rules.Engine.RuleOutcome.Findings => "findings",
|
||
Rules.Engine.RuleOutcome.NotMeasurable => "notMeasurable",
|
||
Rules.Engine.RuleOutcome.NotApplicable => "notApplicable",
|
||
Rules.Engine.RuleOutcome.ManualCheck => "manualCheck",
|
||
_ => "error",
|
||
},
|
||
s.Reason,
|
||
s.EvaluatedAt,
|
||
}));
|
||
}
|
||
|
||
private static async Task<IResult> ReportAsync(string type, InsightRepository repository, Display display, IOptions<WebOptions> options, CancellationToken ct,
|
||
string? format = null, int? days = null, string? instance = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null)
|
||
{
|
||
var pdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
|
||
if (format is { Length: > 0 } && !pdf && !string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return Results.BadRequest("format skal være json eller pdf.");
|
||
}
|
||
|
||
var now = DateTimeOffset.UtcNow;
|
||
var redact = RedactorFor(options);
|
||
switch (type.ToLowerInvariant())
|
||
{
|
||
case "driftsrapport":
|
||
{
|
||
var span = Math.Clamp(days ?? 30, 1, 366);
|
||
var data = await ReportData.LoadAsync(repository, options.Value.Organization, now.AddDays(-span), now, display.Zone, TimeProvider.System, ct).ConfigureAwait(false);
|
||
return pdf
|
||
? Results.File(ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf")
|
||
: Results.Ok(ManagementJson(data, redact));
|
||
}
|
||
|
||
case "analyse":
|
||
{
|
||
if (instance is null || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target)
|
||
{
|
||
return Results.BadRequest("instance er påkrævet og skal være en kendt instans.");
|
||
}
|
||
|
||
var (start, end) = (from ?? now.AddHours(-1), to ?? now);
|
||
if (end <= start || end - start > TimeWindow.MaxLength)
|
||
{
|
||
return Results.BadRequest("to skal ligge efter from, og tidsrummet må højst være 31 dage.");
|
||
}
|
||
|
||
var analysis = await TimeWindowAnalysis.LoadAsync(repository, target, start, end, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(sort), ct).ConfigureAwait(false);
|
||
return pdf
|
||
? Results.File(TimeWindowReport.Render(analysis, options.Value.Organization, display.Zone), "application/pdf", $"analyse-{now:yyyy-MM-dd}.pdf")
|
||
: Results.Ok(AnalysisJson(analysis, redact));
|
||
}
|
||
|
||
case "rettigheder":
|
||
{
|
||
InstanceOverview? target = null;
|
||
if (instance is { Length: > 0 } && (target = await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false)) is null)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var span = Math.Clamp(days ?? 90, 1, 366);
|
||
var audit = await PrivilegeAudit.LoadAsync(repository, target, now.AddDays(-span), now, ct).ConfigureAwait(false);
|
||
return pdf
|
||
? Results.File(audit.Render(options.Value.Organization, display.Zone), "application/pdf", $"rettigheder-{now:yyyy-MM-dd}.pdf")
|
||
: Results.Ok(AuditJson(audit, redact));
|
||
}
|
||
|
||
default:
|
||
return Results.NotFound();
|
||
}
|
||
}
|
||
|
||
private static object ManagementJson(ReportData data, Redactor redact) => new
|
||
{
|
||
data.Organization,
|
||
data.From,
|
||
data.To,
|
||
data.GeneratedAt,
|
||
TimeZone = data.TimeZone.Id,
|
||
data.Version,
|
||
data.RulePack,
|
||
Summary = ManagementReport.SummarySentences(data),
|
||
Period = data.Period,
|
||
Controls = ComplianceControl.All.Select(c => new
|
||
{
|
||
c.Name,
|
||
c.Description,
|
||
c.Rules,
|
||
Instances = data.Instances.Select(i => new { Instance = i.Name, Status = c.StatusOn(i.Name, data.RuleStates).ToString() }),
|
||
}),
|
||
Instances = data.Instances.Select(i => new
|
||
{
|
||
i.Name, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.LastSeenAt, i.IsFailing,
|
||
Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
|
||
}),
|
||
Findings = data.Active.Select(f => FindingSummary(f, redact)),
|
||
ConfigurationChanges = data.ConfigurationChanges.Select(c => ConfigurationChangeJson(c, redact)),
|
||
};
|
||
|
||
private static object ConfigurationChangeJson(StoredConfigurationChange c, Redactor redact)
|
||
{
|
||
// Service accounts are logins: sensitive, as in the configuration rule's evidence.
|
||
var sensitive = c.Area == ConfigurationFingerprint.ServiceAccount;
|
||
return new { Instance = c.InstanceName, c.DetectedAt, c.Area, c.Name, OldValue = redact.Value(c.OldValue, sensitive), NewValue = redact.Value(c.NewValue, sensitive) };
|
||
}
|
||
|
||
private static object AnalysisJson(TimeWindowAnalysis a, Redactor redact) => new
|
||
{
|
||
Instance = a.Instance.Name,
|
||
a.From,
|
||
a.To,
|
||
Resolution = a.Resolution.ToString(),
|
||
a.HasData,
|
||
Metrics = a.Metrics.Select(m => new
|
||
{
|
||
m.Metric, m.Label, m.Unit, m.Average, m.Peak, m.NormalAverage, m.IsUnusual,
|
||
Assessment = AssessmentJson(a.Assess(m)),
|
||
Explanation = MetricAssessment.Explanation(m.Metric),
|
||
Points = m.Points,
|
||
}),
|
||
Waits = a.Waits.Select(w => new { w.WaitType, Category = w.Category.ToString(), w.WaitTimeMs, w.WaitingTasks, w.PerMinute, w.NormalPerMinute, w.Share, w.IsUnusual }),
|
||
TopQueries = a.TopQueries.Select(q => TopQueryJson(q, a.ActivityPercent(q), redact)),
|
||
Disks = a.Disks.Select(d => DiskJson(d.Window, d.Normal)),
|
||
Files = a.Files,
|
||
Blocking = a.Blocking.Select(b => new
|
||
{
|
||
b.SampledAt, b.HeadSessionId, b.BlockedCount, b.MaxWaitMs,
|
||
Sessions = b.Sessions.Select(s => new
|
||
{
|
||
s.SessionId, s.BlockingSessionId, s.Status, s.WaitType, s.WaitTimeMs, s.DatabaseName,
|
||
LoginName = redact.Value(s.LoginName), HostName = redact.Value(s.HostName), ProgramName = redact.Value(s.ProgramName), SqlText = redact.Value(s.SqlText),
|
||
}),
|
||
}),
|
||
Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }),
|
||
LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }),
|
||
LongTransactions = a.LongTransactions.Select(t => new
|
||
{
|
||
t.SessionId, t.BeganAt, t.LastSeenAt, t.DatabaseName, LogMegabytes = t.MaxLogBytes / 1024.0 / 1024.0,
|
||
LoginName = redact.Value(t.LoginName), HostName = redact.Value(t.HostName), ProgramName = redact.Value(t.ProgramName), Statement = redact.Value(t.Statement),
|
||
}),
|
||
Findings = a.Findings.Select(f => FindingSummary(f, redact)),
|
||
};
|
||
|
||
private static object AssessmentJson(Assessment a) => new { Level = a.Level.ToString(), Text = a.LevelText, a.Summary, a.NextStep, a.Link };
|
||
|
||
private static object TopQueryJson(TopQuery q, double? activity, Redactor redact) => new
|
||
{
|
||
q.QueryKey, q.DatabaseName, Text = redact.Value(q.Text), q.Executions, q.WorkerTimeUs, q.ElapsedTimeUs, q.LogicalReads, q.PhysicalReads, q.LogicalWrites,
|
||
ActivityPercent = activity,
|
||
};
|
||
|
||
private static object DiskJson(DiskActivity w, DiskActivity? normal) => new
|
||
{
|
||
w.Volume, w.DataFiles, w.LogFiles, w.ReadIops, w.WriteIops, w.ReadMbPerSecond, w.WriteMbPerSecond, w.AverageReadMs, w.AverageWriteMs,
|
||
Normal = normal is null ? null : new { normal.AverageReadMs, normal.AverageWriteMs, normal.ReadIops, normal.WriteIops },
|
||
Assessment = AssessmentJson(MetricAssessment.AssessDisk(w)),
|
||
};
|
||
|
||
private static object AuditJson(PrivilegeAudit audit, Redactor redact) => new
|
||
{
|
||
audit.From,
|
||
audit.To,
|
||
Method = PrivilegeAudit.MethodText,
|
||
Instances = audit.Instances.Select(e => InstanceAuditJson(e, redact)),
|
||
};
|
||
|
||
private static object InstanceAuditJson(PrivilegeAuditInstance e, Redactor redact) => new
|
||
{
|
||
Instance = e.Instance.Name,
|
||
Measured = e.Snapshot?.CapturedAt,
|
||
SysadminCount = e.Snapshot is null ? (int?)null : e.Sysadmins.Count,
|
||
MaxSysadminAccounts = e.Snapshot?.MaxSysadminAccounts,
|
||
Sysadmins = e.Sysadmins.Select(a => new { Account = redact.Value(a.Account), DisplayName = redact.Value(a.DisplayName), a.Type, Source = a.Source == PrivilegeAnalysis.Direct ? a.Source : redact.Value(a.Source) }),
|
||
Principals = (e.Snapshot?.Principals ?? []).Select(p => new
|
||
{
|
||
Name = redact.Value(p.Name), p.Type, p.Privilege, p.IsDisabled, p.ExpansionError,
|
||
Members = p.Members?.Select(m => new { Account = redact.Value(m.Account), DisplayName = redact.Value(m.DisplayName), m.Enabled }),
|
||
}),
|
||
Sa = e.Snapshot is null ? null : PrivilegeAudit.SaText(e.Sa),
|
||
LoginsWithoutPolicy = e.LoginsWithoutPolicy.Select(l => redact.Value(l.Name)),
|
||
DefaultTrace = e.Snapshot is null ? null : PrivilegeAudit.TraceText(e.Snapshot),
|
||
Changes = e.Changes.Select(c => new
|
||
{
|
||
c.DetectedAt, c.Kind, c.Privilege, Principal = redact.Value(c.Principal), c.PrincipalType, ViaGroup = redact.Value(c.ViaGroup),
|
||
ChangedBy = redact.Value(c.ChangedBy), c.ChangedAt,
|
||
}),
|
||
};
|
||
|
||
private static async Task<(InstanceOverview? Instance, DateTimeOffset From, DateTimeOffset To, IResult? Error)> WindowAsync(
|
||
string name, InsightRepository repository, DateTimeOffset? from, DateTimeOffset? to, TimeSpan defaultLength, CancellationToken ct)
|
||
{
|
||
var instance = await repository.GetInstanceAsync(name, ct).ConfigureAwait(false);
|
||
var now = DateTimeOffset.UtcNow;
|
||
var (start, end) = (from ?? (to ?? now) - defaultLength, to ?? now);
|
||
IResult? error = instance is null ? Results.NotFound()
|
||
: end <= start || end - start > TimeWindow.MaxLength ? Results.BadRequest("to skal ligge efter from, og tidsrummet må højst være 31 dage.")
|
||
: null;
|
||
return (instance, start, end, error);
|
||
}
|
||
|
||
private static async Task<IResult> AnalysisAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
|
||
DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null)
|
||
{
|
||
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(1), ct).ConfigureAwait(false);
|
||
if (error is not null)
|
||
{
|
||
return error;
|
||
}
|
||
|
||
var analysis = await TimeWindowAnalysis.LoadAsync(repository, instance!, start, end, DateTimeOffset.UtcNow, new RetentionOptions().RawDays, AnalysisOrder.Parse(sort), ct)
|
||
.ConfigureAwait(false);
|
||
return Results.Ok(AnalysisJson(analysis, RedactorFor(options)));
|
||
}
|
||
|
||
private static async Task<IResult> TopSqlAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
|
||
DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null, int? limit = null)
|
||
{
|
||
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
|
||
if (error is not null)
|
||
{
|
||
return error;
|
||
}
|
||
|
||
var seconds = (end - start).TotalSeconds;
|
||
var queries = await repository.GetTopQueriesAsync(instance!.InstanceId, start, end, AnalysisOrder.Parse(sort), Math.Clamp(limit ?? 25, 1, 200), ct).ConfigureAwait(false);
|
||
var redact = RedactorFor(options);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
From = start,
|
||
To = end,
|
||
Queries = queries.Select(q => TopQueryJson(q, 100.0 * q.WorkerTimeUs / (seconds * 1_000_000), redact)),
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> DisksAsync(string name, InsightRepository repository, CancellationToken ct, DateTimeOffset? from = null, DateTimeOffset? to = null)
|
||
{
|
||
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
|
||
if (error is not null)
|
||
{
|
||
return error;
|
||
}
|
||
|
||
var hourly = TimeWindow.ChooseResolution(start, DateTimeOffset.UtcNow, new RetentionOptions().RawDays) == WindowResolution.Hourly;
|
||
var disks = hourly
|
||
? await repository.GetDiskActivityHourlyAsync(instance!.InstanceId, start, end, ct).ConfigureAwait(false)
|
||
: await repository.GetDiskActivityAsync(instance!.InstanceId, start, end, ct).ConfigureAwait(false);
|
||
var series = await repository.GetDiskSeriesAsync(instance.InstanceId, start, ct).ConfigureAwait(false);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
From = start,
|
||
To = end,
|
||
Resolution = hourly ? "Hourly" : "Raw",
|
||
Explanation = new { Read = MetricAssessment.Explanation("disk.read"), LogWrite = MetricAssessment.Explanation("disk.write.log"), Write = MetricAssessment.Explanation("disk.write") },
|
||
Drives = disks.Select(d => DiskJson(d, null)),
|
||
Series = series,
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> ConfigurationAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
var id = instance.InstanceId;
|
||
var setup = await repository.GetSnapshotAsync<InstanceConfigurationSnapshot>(id, "instance-config", ct).ConfigureAwait(false);
|
||
var configuration = await repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration", ct).ConfigureAwait(false);
|
||
var traceFlags = await repository.GetSnapshotAsync<TraceFlagSnapshot>(id, "trace-flags", ct).ConfigureAwait(false);
|
||
var now = DateTimeOffset.UtcNow;
|
||
var changes = await repository.GetConfigurationChangesAsync(id, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, 500, ct).ConfigureAwait(false);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
Setup = setup is null ? null : new
|
||
{
|
||
setup.CapturedAt, setup.ProductVersion, setup.ProductLevel, setup.Edition, setup.Collation, setup.ServicesAvailable,
|
||
Services = setup.Services.Select(s => new { s.Name, Account = redact.Value(s.Account), s.StartupType, s.Status, s.InstantFileInitialization }),
|
||
setup.InstantFileInitialization, setup.MemoryModel, setup.TempdbFiles, setup.DefaultDataPath, setup.DefaultLogPath,
|
||
},
|
||
Options = configuration?.Options.Select(o => new { o.Name, o.Value, o.ValueInUse, Default = ConfigurationFingerprint.DefaultValue(o.Name) }),
|
||
TraceFlags = traceFlags?.GlobalTraceFlags,
|
||
Changes = changes.Select(c => ConfigurationChangeJson(c, redact)),
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> InstancePrivilegesAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var now = DateTimeOffset.UtcNow;
|
||
var audit = await PrivilegeAudit.LoadAsync(repository, instance, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, ct).ConfigureAwait(false);
|
||
return Results.Ok(InstanceAuditJson(audit.Instances[0], RedactorFor(options)));
|
||
}
|
||
|
||
private static async Task<IResult> AllPrivilegesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
|
||
{
|
||
var now = DateTimeOffset.UtcNow;
|
||
var audit = await PrivilegeAudit.LoadAsync(repository, null, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, ct).ConfigureAwait(false);
|
||
return Results.Ok(AuditJson(audit, RedactorFor(options)));
|
||
}
|
||
|
||
private static async Task<IResult> ActivityAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
var a = await repository.GetSnapshotAsync<ActivitySnapshot>(instance.InstanceId, "activity", ct).ConfigureAwait(false);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
MeasuredAt = a?.CapturedAt,
|
||
Thresholds = a is null ? null : new { a.TransactionMinutes, a.TransactionLogMegabytes, a.RequestMinutes },
|
||
Transactions = (a?.Transactions ?? []).Select(t => new
|
||
{
|
||
t.SessionId, t.BeganAt, AgeMinutes = t.Age(a!.CapturedAt).TotalMinutes, t.DatabaseName, t.Databases, t.LogMegabytes, t.SessionStatus, t.WaitType,
|
||
LoginName = redact.Value(t.LoginName), HostName = redact.Value(t.HostName), ProgramName = redact.Value(t.ProgramName), Statement = redact.Value(t.Statement),
|
||
}),
|
||
LongRequests = (a?.LongRequests ?? []).Select(r => new
|
||
{
|
||
r.SessionId, r.StartedAt, ElapsedMinutes = r.ElapsedMs / 60000.0, r.Command, r.Status, r.WaitType, r.DatabaseName, r.BlockingSessionId,
|
||
LoginName = redact.Value(r.LoginName), HostName = redact.Value(r.HostName), ProgramName = redact.Value(r.ProgramName), Statement = redact.Value(r.Statement),
|
||
}),
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> TempdbAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
var t = await repository.GetSnapshotAsync<TempdbSnapshot>(instance.InstanceId, "tempdb", ct).ConfigureAwait(false);
|
||
return Results.Ok(t is null ? new { Instance = instance.Name } : (object)new
|
||
{
|
||
Instance = instance.Name,
|
||
MeasuredAt = t.CapturedAt,
|
||
t.SizeMegabytes, t.FreeMegabytes, t.FreePercent, t.CanGrow, t.VersionStoreMegabytes, t.UserObjectsMegabytes, t.InternalObjectsMegabytes,
|
||
t.VersionStoreThresholdMegabytes, t.VersionStoreHistory, t.VersionGenerators,
|
||
OldestSnapshotTransaction = t.OldestSnapshotTransaction is { } o
|
||
? new { o.SessionId, o.ElapsedSeconds, LoginName = redact.Value(o.LoginName), ProgramName = redact.Value(o.ProgramName) }
|
||
: null,
|
||
TopSessions = t.TopSessions.Select(s => new
|
||
{
|
||
s.SessionId, s.Since, s.UsedMegabytes, s.Status, LoginName = redact.Value(s.LoginName), ProgramName = redact.Value(s.ProgramName), HostName = redact.Value(s.HostName),
|
||
}),
|
||
Explanation = new { VersionStore = MetricAssessment.Explanation(Metrics.VersionStoreMegabytes), Used = MetricAssessment.Explanation(Metrics.TempdbUsedMegabytes) },
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> ReplicationAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
var r = await repository.GetSnapshotAsync<ReplicationSnapshot>(instance.InstanceId, "replication", ct).ConfigureAwait(false);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
MeasuredAt = r?.CapturedAt,
|
||
IsDistributor = r?.IsDistributor,
|
||
r?.DistributionDatabase,
|
||
r?.Unavailable,
|
||
UnavailableDetail = redact.Value(r?.UnavailableDetail),
|
||
r?.ErrorsLastDay,
|
||
Agents = (r?.Agents ?? []).Select(a => new { a.Name, a.PublisherDatabase, a.RunStatus, a.StatusText, a.LastRunAt, a.DeliveryLatencyMs, Comment = redact.Value(a.Comment) }),
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> IndexUsageAsync(string name, InsightRepository repository, CancellationToken ct, int? days = null)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var span = Math.Clamp(days ?? 30, 1, 400);
|
||
var now = DateTimeOffset.UtcNow;
|
||
var usage = await repository.GetIndexUsageAsync(instance.InstanceId, now.AddDays(-span), now, 1000, ct).ConfigureAwait(false);
|
||
return Results.Ok(new { Instance = instance.Name, Days = span, Indexes = usage });
|
||
}
|
||
|
||
private static async Task<IResult> ErrorLogAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
|
||
{
|
||
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
var now = DateTimeOffset.UtcNow;
|
||
var snapshot = await repository.GetSnapshotAsync<ErrorLogSnapshot>(instance.InstanceId, "error-log", ct).ConfigureAwait(false);
|
||
var events = await repository.GetErrorLogEventsAsync(instance.InstanceId, now.AddDays(-Math.Clamp(days ?? 7, 1, 90)), now, 500, ct).ConfigureAwait(false);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
Source = snapshot?.Source.ToString(),
|
||
snapshot?.Coverage,
|
||
ReadAt = snapshot?.CapturedAt,
|
||
Events = events.Select(e => new { e.Category, e.Error, e.Severity, e.Count, e.FirstAt, e.LastAt, Sample = redact.Value(e.Sample), e.Source }),
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
|
||
DateTimeOffset? from = null, DateTimeOffset? to = null)
|
||
{
|
||
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
|
||
if (error is not null)
|
||
{
|
||
return error;
|
||
}
|
||
|
||
var type = waitType.Trim().ToUpperInvariant();
|
||
var explanation = WaitKnowledge.Lookup(type);
|
||
var hourly = TimeWindow.ChooseResolution(start, DateTimeOffset.UtcNow, new RetentionOptions().RawDays) == WindowResolution.Hourly;
|
||
var series = await repository.GetWaitTypeSeriesAsync(instance!.InstanceId, type, start, end, hourly, ct).ConfigureAwait(false);
|
||
var statements = await repository.GetWaitTypeStatementsAsync(instance.InstanceId, type, start, end, 25, ct).ConfigureAwait(false);
|
||
var redact = RedactorFor(options);
|
||
return Results.Ok(new
|
||
{
|
||
Instance = instance.Name,
|
||
WaitType = type,
|
||
From = start,
|
||
To = end,
|
||
Explanation = new { explanation.Title, explanation.Meaning, explanation.Normal, explanation.Causes, explanation.Actions, explanation.DynamicsAx, explanation.Known },
|
||
WaitMsPerMinute = series,
|
||
Statements = statements.Select(s => new
|
||
{
|
||
s.QueryKey, s.DatabaseName, Text = redact.Value(s.Text), s.Samples, s.Share, s.EstimatedWaitSeconds, s.AverageWaitMs, s.MaxWaitMs, s.BlockedSamples,
|
||
}),
|
||
});
|
||
}
|
||
}
|