- 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>
646 lines
34 KiB
C#
646 lines
34 KiB
C#
using System.Globalization;
|
||
using Microsoft.Extensions.Options;
|
||
using SQLInsight.Core.Data;
|
||
using SQLInsight.Core.Findings;
|
||
using SQLInsight.Reports;
|
||
using SQLInsight.Repository;
|
||
|
||
namespace SQLInsight.Web.Api;
|
||
|
||
/// <summary>
|
||
/// The read-only JSON API for findings, rules, reports and the pages of the web UI: the same data, windows and filters
|
||
/// as the pages, built from the same models. Customer data (statement texts, logins, names) is sensitive and returned
|
||
/// as "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
|
||
/// </summary>
|
||
public static class ApiV1Endpoints
|
||
{
|
||
public const string Redacted = "[udeladt]";
|
||
|
||
private static readonly string[] ReportFormats = ["json", "pdf"];
|
||
|
||
/// <summary>Report types under /api/v1/reports/{type}.</summary>
|
||
public static readonly IReadOnlyList<(string Type, string Title, string Parameters)> ReportTypes =
|
||
[
|
||
("driftsrapport", "Driftsrapport til ledelse og compliance", "days (1–366, standard 30)"),
|
||
("analyse", "Analyse af tidsrum for én instans", "instance (påkrævet), from, to (ISO 8601; standard seneste time), sort (cpu|varighed|laesninger|koersler)"),
|
||
("rettigheder", "Rettighedsrevision", "days (1–366, standard 90), instance (valgfri; standard alle)"),
|
||
];
|
||
|
||
public static void Map(IEndpointRouteBuilder app)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(app);
|
||
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
|
||
|
||
api.MapGet("/findings", SearchFindingsAsync)
|
||
.WithSummary("Fund: aktive, løste eller alle, med filtre og sider / Findings with filters and paging")
|
||
.WithDescription("Returns an array. More pages: the response header X-Continuation holds the value for ?continuation=.");
|
||
api.MapGet("/findings/{id:long}", FindingAsync).WithSummary("Ét fund med forklaring, anbefaling, målinger, fix-script og historik / One finding");
|
||
api.MapGet("/rules", RulesAsync).WithSummary("Regler: indbyggede, community og lokale / Rules");
|
||
api.MapGet("/instances/{name}/rule-states", RuleStatesAsync).WithSummary("Reglernes udfald på en instans / Rule outcomes on an instance");
|
||
api.MapGet("/reports", () => ReportTypes.Select(r => new { r.Type, r.Title, r.Parameters, Formats = ReportFormats }))
|
||
.WithSummary("Rapporttyper og deres parametre / Report types");
|
||
api.MapGet("/reports/{type}", ReportAsync).WithSummary("En rapport som JSON eller PDF (format=json|pdf) / A report as JSON or PDF");
|
||
api.MapGet("/instances/{name}/analysis", AnalysisAsync).WithSummary("Analyse af tidsrum / Time window analysis");
|
||
api.MapGet("/instances/{name}/top-sql", TopSqlAsync).WithSummary("Tungeste sætninger i et tidsrum / Top SQL in a window");
|
||
api.MapGet("/instances/{name}/disks", DisksAsync).WithSummary("Diskhastighed pr. drev med vurdering og tidsserier / Disk speed per drive");
|
||
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
|
||
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
|
||
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
|
||
api.MapGet("/instances/{name}/activity", ActivityAsync).WithSummary("Åbne transaktioner og lange sætninger lige nu / Open transactions and long-running statements");
|
||
api.MapGet("/instances/{name}/tempdb", TempdbAsync).WithSummary("tempdb og version store lige nu / tempdb and the version store");
|
||
api.MapGet("/instances/{name}/replication", ReplicationAsync).WithSummary("Replikering set fra distributøren / Replication from the distributor");
|
||
api.MapGet("/instances/{name}/index-usage", IndexUsageAsync).WithSummary("Indexbrug over tid, på tværs af genstarter / Index usage over time (days)");
|
||
api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)");
|
||
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
|
||
}
|
||
|
||
private sealed class Redactor(bool include)
|
||
{
|
||
public bool Include => include;
|
||
|
||
public string? Value(string? value, bool sensitive = true) => sensitive && !include && value is not null ? Redacted : value;
|
||
}
|
||
|
||
private static Redactor RedactorFor(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
|
||
|
||
private static object FindingSummary(StoredFinding f, Redactor redact) => new
|
||
{
|
||
Id = f.FindingId,
|
||
Instance = f.InstanceName,
|
||
f.RuleId,
|
||
Category = Category(f.RuleId),
|
||
f.Subject,
|
||
Severity = f.Severity.ToString(),
|
||
f.Title,
|
||
f.Explanation,
|
||
f.Recommendation,
|
||
Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive) }),
|
||
f.FirstSeenAt,
|
||
f.LastSeenAt,
|
||
f.ResolvedAt,
|
||
};
|
||
|
||
/// <summary>SEC in SQLI-SEC-001, FT in COM-FT-002.</summary>
|
||
public static string Category(string ruleId)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(ruleId);
|
||
var parts = ruleId.Split('-');
|
||
return parts.Length >= 3 ? parts[1] : ruleId;
|
||
}
|
||
|
||
private static async Task<IResult> SearchFindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
|
||
string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? instance = null, string? database = null,
|
||
string? ruleId = null, Severity? minSeverity = null, string? category = null, string? sort = null, int? limit = null, string? continuation = null)
|
||
{
|
||
var findingStatus = status?.ToLowerInvariant() switch
|
||
{
|
||
null or "" or "active" => (FindingStatus?)FindingStatus.Active,
|
||
"resolved" => FindingStatus.Resolved,
|
||
"all" => FindingStatus.All,
|
||
_ => null,
|
||
};
|
||
var findingSort = sort?.ToLowerInvariant() switch
|
||
{
|
||
null or "" or "severity" => (FindingSort?)FindingSort.Severity,
|
||
"firstseen" => FindingSort.FirstSeen,
|
||
"lastseen" => FindingSort.LastSeen,
|
||
"resolved" => FindingSort.Resolved,
|
||
_ => null,
|
||
};
|
||
if (findingStatus is null || findingSort is null)
|
||
{
|
||
return Results.BadRequest("status skal være active, resolved eller all; sort skal være severity, firstSeen, lastSeen eller resolved.");
|
||
}
|
||
|
||
var offset = 0;
|
||
if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
|
||
{
|
||
return Results.BadRequest("Ugyldig continuation.");
|
||
}
|
||
|
||
var size = Math.Clamp(limit ?? 100, 1, InsightRepository.MaxFindingPage);
|
||
var (findings, more) = await repository.SearchFindingsAsync(new FindingQuery
|
||
{
|
||
Status = findingStatus.Value,
|
||
From = from,
|
||
To = to,
|
||
Instance = instance,
|
||
Database = database,
|
||
RuleId = ruleId,
|
||
MinSeverity = minSeverity,
|
||
Category = category,
|
||
Sort = findingSort.Value,
|
||
Offset = offset,
|
||
Limit = size,
|
||
}, ct).ConfigureAwait(false);
|
||
if (more)
|
||
{
|
||
http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
return Results.Ok(findings.Select(f => FindingSummary(f, redact)));
|
||
}
|
||
|
||
private static async Task<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
|
||
{
|
||
if (await repository.GetFindingAsync(id, ct).ConfigureAwait(false) is not { } f)
|
||
{
|
||
return Results.NotFound();
|
||
}
|
||
|
||
var redact = RedactorFor(options);
|
||
return Results.Ok(new
|
||
{
|
||
Id = f.FindingId,
|
||
Instance = f.InstanceName,
|
||
f.RuleId,
|
||
Category = Category(f.RuleId),
|
||
f.Subject,
|
||
Severity = f.Severity.ToString(),
|
||
f.Title,
|
||
f.Explanation,
|
||
f.Recommendation,
|
||
Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive), e.Sensitive }),
|
||
f.FixScript,
|
||
References = f.References.Select(r => new { r.Title, r.Url }),
|
||
History = new { f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt, Status = f.ResolvedAt is null ? "active" : "resolved" },
|
||
});
|
||
}
|
||
|
||
private static async Task<IResult> RulesAsync(InsightRepository repository, CancellationToken ct)
|
||
{
|
||
var builtIn = Rules.BuiltInRules.Create().Select(r => (r.Id, r.Title, Source: "indbygget"))
|
||
.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,
|
||
}),
|
||
});
|
||
}
|
||
}
|