- /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>
120 lines
6.2 KiB
C#
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);
|
|
}
|