sqlinsight-pro/src/SQLInsight.Web/Api/ApiV1Endpoints.cs
Søren Eggert Lundsteen Olsen 1c18bcfab8 Web: Windows-servere and Syntetiske tjek pages, and the same in API v1 (scope config:write)
- Menu group "Servere" with Windows-servere (/servere) and Syntetiske tjek
  (/syntetiske-tjek).
- /servere: every server with status (overvåges, delvist målbar, kan ikke læses),
  CPU, free memory, the fullest disk and the nearest "fuld om ca. N dage", findings;
  add application/AOS/file servers. /servere/{id}: findings and what is not
  measurable and why, CPU/memory for the last day, disks with forecasts, watched
  and stopped services, grouped event log errors, scheduled tasks, rights per area
  with the latest test, the least-privilege PowerShell script (copy or download,
  UTF-8 with BOM), role and service lists, pause and remove. /servere/fund/{id}.
- /syntetiske-tjek: checks with status and response time; one form for every kind
  (TCP, HTTP(S), SQL login + query, DNS, AX logon) with an optional server and
  service; a SQL login password is sealed for the agent at once and never shown.
  /syntetiske-tjek/{id}: latest result (incl. "kun porten er testet"), findings,
  response times, history, edit, run now, pause, remove.
- API v1: /windows-servers (+ test, grant-script, performance, disks, services,
  events, tasks, rule-states), /server-findings, /synthetic-checks (+ run, results,
  rule-states); all in OpenAPI. Passwords are write-only; event samples, failure
  messages and sensitive evidence are "[udeladt]" unless
  SQLInsight:Web:ApiIncludesSensitiveEvidence is on. /api/v1/rules lists the new rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:52 +02:00

646 lines
34 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}/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"))
.Concat(Rules.Servers.ServerRuleCatalog.All.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, IOptions<WebOptions> options, 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",
},
// A reason may name the agent's login (the GRANT text of the Dynamics AX rules, or a permission error): redacted like logins elsewhere.
Reason = RedactorFor(options).Include ? s.Reason : Core.DynamicsAx.AxReadAccess.WithoutPrincipals(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,
}),
});
}
}