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>
142 lines
6.7 KiB
C#
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 æ 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;
|