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; /// /// 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. /// public static class ApiV1Endpoints { public const string Redacted = "[udeladt]"; private static readonly string[] ReportFormats = ["json", "pdf"]; /// Report types under /api/v1/reports/{type}. 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}/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 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, }; /// SEC in SQLI-SEC-001, FT in COM-FT-002. public static string Category(string ruleId) { ArgumentNullException.ThrowIfNull(ruleId); var parts = ruleId.Split('-'); return parts.Length >= 3 ? parts[1] : ruleId; } private static async Task SearchFindingsAsync(HttpContext http, InsightRepository repository, IOptions 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 FindingAsync(long id, InsightRepository repository, IOptions 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 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 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 ReportAsync(string type, InsightRepository repository, Display display, IOptions 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 AnalysisAsync(string name, InsightRepository repository, IOptions 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 TopSqlAsync(string name, InsightRepository repository, IOptions 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 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 ConfigurationAsync(string name, InsightRepository repository, IOptions 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(id, "instance-config", ct).ConfigureAwait(false); var configuration = await repository.GetSnapshotAsync(id, "configuration", ct).ConfigureAwait(false); var traceFlags = await repository.GetSnapshotAsync(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 InstancePrivilegesAsync(string name, InsightRepository repository, IOptions 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 AllPrivilegesAsync(InsightRepository repository, IOptions 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 ActivityAsync(string name, InsightRepository repository, IOptions 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(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 TempdbAsync(string name, InsightRepository repository, IOptions 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(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 ErrorLogAsync(string name, InsightRepository repository, IOptions 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(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 WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions 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, }), }); } }