A five-minute collector reads important error log entries from the best
source the agent's rights allow: the error log itself (xp_readerrorlog,
only with securityadmin), the default trace's ErrorLog and Audit Login
Failed events (optional ALTER TRACE), or the exception and connectivity
ring buffers (VIEW SERVER STATE: numbers and severity, failed logins).
Repetitions are summed per error; the watermark is the newest entry
read, because the default trace is buffered. Stored in
insight.ErrorLogEvents (migration 0013, EventDays), resumed after restart.
Rules SQLI-LOG-001..006: corruption 823/824/825, I/O over 15 s (833),
severe errors (17+), login failure bursts (configurable), stack dumps and
AG errors; rules needing the log text are "not measurable" with the
right to grant. Page /instanser/{navn}/fejllog (menu Overvågning) and
GET /api/v1/instances/{name}/error-log with messages redacted.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
547 lines
28 KiB
C#
547 lines
28 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}/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 }),
|
||
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> 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,
|
||
}),
|
||
});
|
||
}
|
||
}
|