sqlinsight-pro/src/SQLInsight.Web/Api/ApiV1Endpoints.cs
Søren Eggert Lundsteen Olsen e6840e40d7 SQL Server error log without elevated rights (8a)
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>
2026-10-02 10:30:41 +02:00

547 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}),
});
}
}