sqlinsight-pro/src/SQLInsight.Web/Program.cs
Søren Eggert Lundsteen Olsen 554a6a71f5 Time window analysis: what happened between 03 and 04, as a page and PDF
SQLInsight Web: /instanser/{navn}/analyse?fra=&til= (datetime-local in the
display time zone; presets last hour, tonight 00-06, yesterday; at most 31
days). "Analysér et tidsrum" on the instance page, and blocking/deadlock
times there open the window half an hour around the event.

For the window: load (CPU, batch requests, compilations, PLE, connections,
memory grants pending, blocked processes) from 5-minute samples within
Retention:RawDays, otherwise hourly history (shown on the page); waits per
minute against the same hours and weekday of the previous 4 weeks, with
clear deviations marked; top SQL sortable by CPU, duration, reads and
executions; file I/O latency; blocking chains; deadlocks; findings active
in the window. An empty window explains the retention. "Hent som PDF"
renders the same via MigraDoc (/analyse.pdf).

Docs: docs/analyse.md (also on the community site); databehandling.md: the
statement text stays in the customer's repository and is never sent to
the hub.

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

142 lines
6.7 KiB
C#

using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Authorization;
using SQLInsight.Repository;
using SQLInsight.Web;
using SQLInsight.Web.Api;
using SQLInsight.Web.Components;
if (args.FirstOrDefault() == "new-api-token")
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
var (token, hash) = ApiTokenHandler.Create();
var name = args.ElementAtOrDefault(1) ?? "prometheus";
Console.WriteLine($"Token (vises kun nu, gem det i det system, der skal hente data): {token}");
Console.WriteLine($"Tilføj i appsettings.json under SQLInsight:Web:ApiTokens: \"{name}\": \"{hash}\"");
return;
}
var builder = WebApplication.CreateBuilder(args);
InstallerSettings.Add(builder.Configuration, AppContext.BaseDirectory);
// Runs as a Windows service with its own Kestrel web server, or behind IIS (ASP.NET Core Module), or
// as a systemd service on Linux. Each call is a no-op when not hosted that way.
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Web");
builder.Services.AddSystemd();
builder.Services.Configure<WebOptions>(builder.Configuration.GetSection(WebOptions.SectionName));
var web = builder.Configuration.GetSection(WebOptions.SectionName).Get<WebOptions>() ?? new WebOptions();
var connectionString = builder.Configuration["SQLInsight:Repository:ConnectionString"];
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("SQLInsight:Repository:ConnectionString mangler. Angiv forbindelsen til repository-databasen.");
}
builder.Services.AddSingleton(new InsightRepository(connectionString));
builder.Services.AddSingleton<Display>();
// Danish text (æ, ø, å) as characters rather than &#xE6; entities. HTML-significant characters are still encoded.
builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
builder.Services.AddRazorComponents();
var windowsAuth = string.Equals(web.Authentication, "Windows", StringComparison.OrdinalIgnoreCase);
if (windowsAuth)
{
// Browsers log in with Windows authentication; machines calling /api/ or /metrics may use a bearer token instead.
builder.Services.AddAuthentication("Smart")
.AddPolicyScheme("Smart", "Windows eller API-token", o => o.ForwardDefaultSelector = context =>
ApiTokenHandler.AppliesTo(context.Request.Path) && context.Request.Headers.Authorization.ToString().StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? ApiTokenHandler.SchemeName
: NegotiateDefaults.AuthenticationScheme)
.AddNegotiate()
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, ApiTokenHandler>(ApiTokenHandler.SchemeName, null);
builder.Services.AddAuthorization(options =>
{
var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser();
if (web.AllowedGroups.Count > 0)
{
policy.RequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == ApiTokenHandler.ClaimType) || web.AllowedGroups.Any(ctx.User.IsInRole));
}
options.FallbackPolicy = policy.Build();
});
}
else if (!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("SQLInsight:Web:Authentication = None er kun tilladt i Development. Web-UI'et viser oplysninger om jeres SQL Servere.");
}
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/fejl", createScopeForErrors: true);
app.UseHsts();
}
app.Use(async (context, next) =>
{
context.Response.Headers.XContentTypeOptions = "nosniff";
context.Response.Headers.XFrameOptions = "DENY";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:";
await next();
});
if (windowsAuth)
{
app.UseAuthentication();
app.UseAuthorization();
}
app.UseAntiforgery();
app.MapStaticAssets();
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
// Deadlock graph as .xdl, which SSMS opens as a graphical deadlock graph.
app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository repository, CancellationToken ct) =>
await repository.GetDeadlockAsync(id, ct) is { } deadlock
? Results.File(System.Text.Encoding.UTF8.GetBytes(deadlock.GraphXml), "application/xml", $"deadlock-{id}.xdl")
: Results.NotFound());
IntegrationEndpoints.Map(app);
Support.Map(app);
// Management and compliance report as PDF, for the last N days.
app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
{
var days = Math.Clamp(dage ?? 30, 1, 366);
var settings = options.Value;
var zone = settings.TimeZone is { Length: > 0 } id ? TimeZoneInfo.FindSystemTimeZoneById(id) : TimeZoneInfo.Local;
var now = DateTimeOffset.UtcNow;
var data = await SQLInsight.Reports.ReportData.LoadAsync(repository, settings.Organization, now.AddDays(-days), now, zone, TimeProvider.System, ct);
return Results.File(SQLInsight.Reports.ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf");
});
// Time window analysis as PDF. Contains statement texts: produced here, for the person who is logged in.
app.MapGet("/analyse.pdf", async (string? instans, string? fra, string? til, string? sortering, InsightRepository repository, Display display,
Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
{
if (instans is null || await repository.GetInstanceAsync(instans, ct) is not { } instance)
{
return Results.NotFound();
}
if (!SQLInsight.Reports.TimeWindow.TryParse(fra, til, display.Zone, out var from, out var to, out var error))
{
return Results.BadRequest(error);
}
var now = DateTimeOffset.UtcNow;
var analysis = await SQLInsight.Reports.TimeWindowAnalysis.LoadAsync(repository, instance, from, to, now,
new RetentionOptions().RawDays, SQLInsight.Reports.AnalysisOrder.Parse(sortering), ct);
var file = $"analyse-{string.Concat(instance.Name.Select(c => char.IsLetterOrDigit(c) || c == '-' ? c : '_'))}-{SQLInsight.Reports.TimeWindow.ToInput(from, display.Zone).Replace(':', '-')}.pdf";
return Results.File(SQLInsight.Reports.TimeWindowReport.Render(analysis, options.Value.Organization, display.Zone), "application/pdf", file);
});
app.MapRazorComponents<App>();
// The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader.
app.Run();
/// <summary>Entry point, visible to integration tests.</summary>
public partial class Program;