sqlinsight-pro/src/SQLInsight.Web/Api/IntegrationEndpoints.cs
Søren Eggert Lundsteen Olsen 30d89acbd7 API v1: findings, rules, reports and page data as JSON, with OpenAPI
- /api/v1/findings gains status (active/resolved/all), from/to, database,
  ruleId, category, sort and paging (limit + X-Continuation header; the
  response stays an array). /findings/{id} with fix script and history.
- /rules (built-in, community, local) and /instances/{name}/rule-states.
- /reports and /reports/{driftsrapport|analyse|rettigheder} as JSON or PDF,
  built from the same models as the pages and PDFs.
- Page data: analysis, top-sql, disks, configuration, privileges (one or
  all instances) and wait types. Statement texts, logins, names and
  service accounts are "[udeladt]" unless ApiIncludesSensitiveEvidence.
- OpenAPI at /api/v1/openapi.json; a test fails when an endpoint is
  missing from it.
- API tokens get scopes ("read", "support:write"); the old form is read
  only, and new-api-token takes --scope. Token names show as token:<name>.

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

120 lines
6.2 KiB
C#

using System.Globalization;
using System.Reflection;
using System.Text;
using SQLInsight.Core.Findings;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>
/// Read-only integration: JSON for scripts and other tools, and the Prometheus text format for Prometheus/Grafana.
/// </summary>
public static class IntegrationEndpoints
{
/// <summary>An instance counts as up when the last contact succeeded and is this recent.</summary>
public static readonly TimeSpan UpWindow = TimeSpan.FromMinutes(10);
public static void Map(IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
api.MapGet("/instances", async (InsightRepository repository, CancellationToken ct) =>
(await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Select(i => new
{
i.Name,
i.ProductVersion,
i.ProductLevel,
i.ProductUpdateLevel,
i.Edition,
i.LastSeenAt,
Up = IsUp(i),
i.LastError,
Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
i.NotMeasurable,
}))
.WithSummary("Instanser med status og antal fund / Instances");
app.MapGet("/metrics", async (InsightRepository repository, CancellationToken ct) =>
Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8")).RequireRead();
}
private static bool IsUp(InstanceOverview i) => !i.IsFailing && i.LastSeenAt is { } seen && DateTime.UtcNow - seen < UpWindow;
public static async Task<string> PrometheusAsync(InsightRepository repository, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
var instances = await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false);
var findings = await repository.GetActiveFindingsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var metrics = await repository.GetLatestMetricsAsync(TimeSpan.FromMinutes(15), cancellationToken).ConfigureAwait(false);
var update = await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false);
var text = new StringBuilder();
var version = typeof(IntegrationEndpoints).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.Split('+')[0] ?? "0.0.0";
Family(text, "sqlinsight_build_info", "Version of SQLInsight Pro and the rule pack in use.");
Sample(text, "sqlinsight_build_info", [("version", version), ("rulepack", update?.RulePackVersion ?? "")], 1);
Family(text, "sqlinsight_instance_up", "1 if the last contact with the instance succeeded within 10 minutes.");
foreach (var i in instances)
{
Sample(text, "sqlinsight_instance_up", [("instance", i.Name)], IsUp(i) ? 1 : 0);
}
Family(text, "sqlinsight_instance_last_seen_timestamp_seconds", "Unix time of the last successful contact.");
foreach (var i in instances.Where(i => i.LastSeenAt is not null))
{
Sample(text, "sqlinsight_instance_last_seen_timestamp_seconds", [("instance", i.Name)], new DateTimeOffset(DateTime.SpecifyKind(i.LastSeenAt!.Value, DateTimeKind.Utc)).ToUnixTimeSeconds());
}
Family(text, "sqlinsight_instance_info", "Version and edition of each instance.");
foreach (var i in instances)
{
Sample(text, "sqlinsight_instance_info", [("instance", i.Name), ("version", i.ProductVersion ?? ""), ("level", $"{i.ProductLevel} {i.ProductUpdateLevel}".Trim()), ("edition", i.Edition ?? "")], 1);
}
Family(text, "sqlinsight_findings", "Active findings per instance and severity.");
foreach (var i in instances)
{
foreach (var (severity, count) in new[] { ("critical", i.Critical), ("high", i.High), ("medium", i.Medium), ("low", i.Low), ("info", i.Info) })
{
Sample(text, "sqlinsight_findings", [("instance", i.Name), ("severity", severity)], count);
}
}
Family(text, "sqlinsight_rule_findings", "Active findings per instance and rule.");
foreach (var group in findings.GroupBy(f => (f.InstanceName, f.RuleId)).OrderBy(g => g.Key))
{
Sample(text, "sqlinsight_rule_findings",
[("instance", group.Key.InstanceName), ("rule", group.Key.RuleId), ("severity", group.Max(f => f.Severity).ToString().ToLowerInvariant())], group.Count());
}
Family(text, "sqlinsight_rules_not_measurable", "Rules that could not be evaluated (version, permissions or errors).");
foreach (var i in instances)
{
Sample(text, "sqlinsight_rules_not_measurable", [("instance", i.Name)], i.NotMeasurable);
}
Family(text, "sqlinsight_metric", "Newest sample of each collected metric (cpu.sqlserver in %, memory.ple in s, workload.* per second, …).");
foreach (var m in metrics)
{
Sample(text, "sqlinsight_metric", [("instance", m.Instance), ("metric", m.Metric)], m.Value);
}
return text.ToString();
}
private static void Family(StringBuilder text, string name, string help) =>
text.Append("# HELP ").Append(name).Append(' ').Append(help).Append('\n').Append("# TYPE ").Append(name).Append(" gauge\n");
private static void Sample(StringBuilder text, string name, (string Name, string Value)[] labels, double value)
{
text.Append(name).Append('{');
for (var i = 0; i < labels.Length; i++)
{
text.Append(i == 0 ? "" : ",").Append(labels[i].Name).Append("=\"").Append(Escape(labels[i].Value)).Append('"');
}
text.Append("} ").Append(value.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
}
/// <summary>Label value escaping from the Prometheus text format: backslash, double quote and newline.</summary>
public static string Escape(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
}