diff --git a/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs b/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs new file mode 100644 index 0000000..3dd2b49 --- /dev/null +++ b/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs @@ -0,0 +1,278 @@ +using Microsoft.Extensions.Options; +using SQLInsight.Core.DynamicsAx; +using SQLInsight.Repository; + +namespace SQLInsight.Web.Api; + +/// +/// Everything under Dynamics AX in SQLInsight Web, through API v1 (scope read): the recognised AX databases with read +/// access and GRANT text, batch jobs and their runs, number sequences, RecId, the daily samples behind the forecasts, +/// and AX users behind blocking. Built from the same data as the pages (). AX users, hosts, +/// programs, statements and the agent's login are sensitive and returned as "[udeladt]" unless +/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on. +/// +public static class DynamicsAxApiEndpoints +{ + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + var api = app.MapGroup("/api/v1/dynamics-ax").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead(); + const string filter = "Optional ?instance= limits it to one instance."; + + api.MapGet("/databases", DatabasesAsync) + .WithSummary("AX-databaser: version, skema, firmaer og læseadgang / Recognised Dynamics AX databases") + .WithDescription("Version (verified only for Axapta 3.0), schema owner (bmssa/dbo), model database, companies, which AX tables the agent may read, the missing tables and the GRANT that gives them. " + filter); + api.MapGet("/grant-script", GrantScriptAsync) + .WithSummary("GRANT-sætninger til den valgfri AX-læseadgang / GRANT statements for the optional AX read access") + .WithDescription("?instance= and ?database= are required. Returns the exact statements for the database (only SELECT on the version's AX system tables) and the full grant-dynamics-ax-read.sql with database and login filled in."); + api.MapGet("/batch-jobs", BatchJobsAsync) + .WithSummary("AX-batchjob lige nu med deres egen historik / Dynamics AX batch jobs now") + .WithDescription("Status, planned and actual start (UTC), how late or how long running, median and 90th percentile of the job's own runs, the raw STATUS counts. " + filter); + api.MapGet("/batch-runs", BatchRunsAsync) + .WithSummary("Afsluttede AX-batchkørsler / Completed Dynamics AX batch runs") + .WithDescription("From SQLInsight's repository, newest first. ?instance=, ?database=, ?job= (job key) and ?days= (1–365, default 7) filter; at most 1000."); + api.MapGet("/number-sequences", NumberSequencesAsync) + .WithSummary("AX-nummerserier med forbrug og prognose / Dynamics AX number sequences") + .WithDescription("Used share, format capacity, numbers per day and days left from SQLInsight's own daily samples, blocked/continuous flags. " + filter); + api.MapGet("/recids", RecIdsAsync) + .WithSummary("AX RecId pr. tabel med prognose / Dynamics AX RecId per table") + .WithDescription("NEXTVAL against the limit (32-bit in Axapta 3.0), per day and days left. " + filter); + api.MapGet("/sequence-samples", SamplesAsync) + .WithSummary("Daglige målinger bag prognoserne / Daily samples behind the forecasts") + .WithDescription("?instance= is required; ?database=, ?kind= (N = number sequence, R = RecId), ?key= and ?days= (1–400, default 30) filter."); + api.MapGet("/blocking", BlockingAsync) + .WithSummary("AX-brugere bag blokering / Dynamics AX users behind blocking") + .WithDescription("Blocking in AX databases with the AX user from CONTEXT_INFO (AX 2009/2012 with connectioncontext; never in Axapta 3.0/4.0, which the explanation says). ?days= (1–365, default 7). " + filter); + } + + private sealed class Redactor(bool include) + { + public string? Value(string? value) => !include && value is not null ? ApiV1Endpoints.Redacted : value; + } + + private static Redactor RedactorFor(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence); + + private static async Task<(IReadOnlyList Views, IResult? Error)> LoadAsync(InsightRepository repository, string? instance, CancellationToken ct) + { + if (instance is { Length: > 0 } && await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is null) + { + return ([], Results.NotFound()); + } + + return (await DynamicsAxView.LoadAsync(repository, instance, ct).ConfigureAwait(false), null); + } + + private static async Task DatabasesAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null) + { + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + var redact = RedactorFor(options); + return Results.Ok(views.SelectMany(v => v.Databases.Databases.Select(d => + { + var missing = AxReadAccess.Missing(d); + return new + { + Instance = v.Instance.Name, + d.Database, + Version = d.Version.ToString(), + VersionText = d.VersionText, + d.Verified, + Verification = AxVersions.VerificationText(d.Version), + d.Schema, + d.ModelDatabase, + Companies = d.Companies?.Select(c => new { c.Id, c.Name, c.IsVirtual }), + d.CompaniesReason, + Isolation = d.Isolation.ToString(), + d.Notes, + ReadAccess = AxReadAccess.Tables(d).Select(t => new { Table = d.Schema + "." + t, Readable = d.CanRead(t) }), + MissingTables = missing, + Grant = missing.Count == 0 ? null : RedactLogin(AxReadAccess.GrantSql(d, d.AgentLogin ?? v.Databases.AgentLogin, missing), d.AgentLogin ?? v.Databases.AgentLogin, redact), + DetectedAt = v.Databases.CapturedAt, + }; + }))); + } + + /// The agent's login is sensitive like every login: replaced by the placeholder unless sensitive values are allowed. + private static string RedactLogin(string sql, string? login, Redactor redact) => + login is { Length: > 0 } && redact.Value(login) != login + ? sql.Replace(AxSql.Identifier(login), AxSql.Identifier(AxReadAccess.LoginPlaceholder), StringComparison.Ordinal) + .Replace(AxSql.Literal(login), AxSql.Literal(AxReadAccess.LoginPlaceholder), StringComparison.Ordinal) + : sql; + + private static async Task GrantScriptAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null, string? database = null) + { + if (instance is not { Length: > 0 } || database is not { Length: > 0 }) + { + return Results.BadRequest("instance og database er påkrævet."); + } + + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + if (views.SingleOrDefault()?.Databases is not { } snapshot || snapshot.Find(database) is not { } ax) + { + return Results.NotFound(); + } + + var redact = RedactorFor(options); + var login = ax.AgentLogin ?? snapshot.AgentLogin; + var shownLogin = redact.Value(login) == login ? login : AxReadAccess.LoginPlaceholder; + return Results.Ok(new + { + Instance = instance, + ax.Database, + Version = ax.Version.ToString(), + ax.Schema, + Tables = AxReadAccess.Tables(ax), + MissingTables = AxReadAccess.Missing(ax), + Statements = AxReadAccess.GrantSql(ax, shownLogin), + Script = AxReadAccess.Script(ax.Database, shownLogin), + RevokeScript = AxReadAccess.RevokeScriptPath, + }); + } + + private static async Task BatchJobsAsync(InsightRepository repository, CancellationToken ct, string? instance = null) + { + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + return Results.Ok(views.Where(v => v.Batch is not null).SelectMany(v => v.Batch!.Databases.Select(d => new + { + Instance = v.Instance.Name, + d.Database, + Version = d.Version.ToString(), + v.Batch!.CapturedAt, + v.Batch.Thresholds, + d.NotMeasurable, + d.Notes, + StatusCounts = d.StatusCounts.ToDictionary(s => s.Key.ToString(System.Globalization.CultureInfo.InvariantCulture), s => s.Value), + Jobs = d.Jobs.Select(j => new + { + j.Key, j.RecId, j.Caption, j.Company, j.Group, Status = j.Status.ToString(), j.StatusCode, j.PlannedStartUtc, j.StartedUtc, j.EndedUtc, + j.FirstSeenExecutingUtc, RunningMinutes = j.Running(v.Batch.CapturedAt)?.TotalMinutes, LateMinutes = j.Late(v.Batch.CapturedAt)?.TotalMinutes, + j.HistoryRuns, j.MedianSeconds, j.P90Seconds, j.LastRunSeconds, LastRunStatus = j.LastRunStatus?.ToString(), j.LastRunEndedUtc, + }), + }))); + } + + private static async Task BatchRunsAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? job = null, + int? days = null) + { + InstanceOverview? target = null; + if (instance is { Length: > 0 } && (target = await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false)) is null) + { + return Results.NotFound(); + } + + var now = DateTimeOffset.UtcNow; + var runs = await repository.GetAxBatchRunsAsync(target?.InstanceId, database, job, now.AddDays(-Math.Clamp(days ?? 7, 1, 365)), now, 1000, ct).ConfigureAwait(false); + return Results.Ok(runs.Select(r => new + { + Instance = r.InstanceName, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, r.StartedAt, r.EndedAt, DurationSeconds = r.Duration.TotalSeconds, + Status = r.Status.ToString(), + })); + } + + private static async Task NumberSequencesAsync(InsightRepository repository, CancellationToken ct, string? instance = null) + { + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new + { + Instance = v.Instance.Name, + d.Database, + Version = d.Version.ToString(), + v.Sequences!.CapturedAt, + NotMeasurable = d.NumberSequencesNotMeasurable, + d.Notes, + Sequences = d.NumberSequences.Select(s => new + { + s.Key, s.Company, s.Code, s.Text, s.Lowest, s.Highest, s.Next, s.Format, s.FormatDigits, s.FormatCapacity, s.FormatTooShort, s.Max, s.UsedPercent, + s.Remaining, s.Blocked, s.Referenced, s.Continuous, s.Cyclic, s.Manual, s.LastCleanUtc, s.CleanIntervalHours, + PerDay = s.Forecast?.PerDay, DaysLeft = s.Forecast?.DaysLeft, ForecastBasisDays = s.Forecast?.BasisDays, + }), + }))); + } + + private static async Task RecIdsAsync(InsightRepository repository, CancellationToken ct, string? instance = null) + { + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new + { + Instance = v.Instance.Name, + d.Database, + Version = d.Version.ToString(), + v.Sequences!.CapturedAt, + NotMeasurable = d.RecIdsNotMeasurable, + RecIds = d.RecIds.Select(r => new + { + r.Key, r.TableId, r.TableName, r.Company, r.Next, r.Max, r.Bits, r.UsedPercent, r.Remaining, + PerDay = r.Forecast?.PerDay, DaysLeft = r.Forecast?.DaysLeft, ForecastBasisDays = r.Forecast?.BasisDays, + }), + }))); + } + + private static async Task SamplesAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? kind = null, + string? key = null, int? days = null) + { + if (instance is not { Length: > 0 } || 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."); + } + + char? k = kind?.ToUpperInvariant() switch + { + null or "" => null, + "N" => AxSequenceSnapshot.NumberSequenceKind, + "R" => AxSequenceSnapshot.RecIdKind, + _ => '?', + }; + if (k == '?') + { + return Results.BadRequest("kind skal være N (nummerserie) eller R (RecId)."); + } + + var samples = await repository.GetAxSequenceSamplesAsync(target.InstanceId, database, k, key, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 30, 1, 400)), ct) + .ConfigureAwait(false); + return Results.Ok(samples.Take(10_000).Select(s => new { s.DatabaseName, Kind = s.Kind.ToString(), s.SequenceKey, s.SampledAt, s.Value })); + } + + private static async Task BlockingAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null, int? days = null) + { + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + var redact = RedactorFor(options); + var rows = await DynamicsAxView.BlockingAsync(repository, views, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 7, 1, 365)), ct).ConfigureAwait(false); + return Results.Ok(rows.Take(1000).Select(r => new + { + r.Instance, r.Database, Version = r.Version.ToString(), r.SampledAt, r.BlockedCount, r.MaxWaitMs, + AxUser = redact.Value(r.Identity.AxUser), r.Identity.AxSessionId, r.Identity.Source, Explanation = redact.Value(r.Identity.Explanation), + Session = r.Head.SessionId, r.Head.Status, r.Head.OpenTransactionCount, + HostName = redact.Value(r.Head.HostName), ProgramName = redact.Value(r.Head.ProgramName), LoginName = redact.Value(r.Head.LoginName), + SqlText = redact.Value(r.Head.SqlText), ContextInfo = redact.Value(r.Identity.RawContext), + })); + } +} diff --git a/src/SQLInsight.Web/Components/Layout/MainNav.razor b/src/SQLInsight.Web/Components/Layout/MainNav.razor index bb4117b..8e7fd61 100644 --- a/src/SQLInsight.Web/Components/Layout/MainNav.razor +++ b/src/SQLInsight.Web/Components/Layout/MainNav.razor @@ -62,7 +62,14 @@ new("Rapporter", "/rapporter"), new("Revisionsrapport", "/rapporter#rettighedsrevision"), ]), - new("ax", "Dynamics AX", "cube", []), + new("ax", "Dynamics AX", "cube", + [ + new("AX-databaser", "/dynamics-ax/databaser"), + new("Batchjob", "/dynamics-ax/batch"), + new("Nummerserier", "/dynamics-ax/nummerserier"), + new("RecId", "/dynamics-ax/recid"), + new("AX-brugere bag blokering", "/dynamics-ax/blokering"), + ]), ]; private string _path = "/"; diff --git a/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor b/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor new file mode 100644 index 0000000..0e401b5 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor @@ -0,0 +1,133 @@ +@page "/dynamics-ax/batch" +@using SQLInsight.Core.DynamicsAx +@inject InsightRepository Repository +@inject Display Display + +Batchjob · Dynamics AX · SQLInsight Pro +

Batchjob

+

Dynamics AX-batchjob i de overvågede AX-databaser: job i fejl, kørende, forsinkede og hængende, sammenlignet med jobbets egen historik. Læses hvert 5. minut fra AX' standardtabeller; historikken gemmes i SQLInsights eget repository.

+ +@if (_views.Count == 0) +{ +

Ingen overvågede databaser er genkendt som Dynamics AX. Se AX-databaser.

+} + +@foreach (var view in _views) +{ + if (view.Batch is null) + { +

@view.Instance.Name: batchjob er endnu ikke læst (første gang inden for 5 minutter).

+ continue; + } + + var now = view.Batch.CapturedAt; + foreach (var db in view.Batch.Databases) + { +
+

@db.Database på @view.Instance.Name · @AxVersions.Text(db.Version)

læst @Display.Ago(now.UtcDateTime)
+ @if (db.NotMeasurable is { } reason) + { + + } + else + { + @foreach (var note in db.Notes) + { +

@note

+ } + + @if (db.Jobs.Count == 0) + { +

Ingen batchjob.

+ } + else + { + + + + @foreach (var job in db.Jobs.Take(200)) + { + var late = job.Late(now); + var running = job.Running(now); + + + + + + + + + + + } + +
StatusBatchjobFirmaPlanlagt startKører / forsinketMedian90 %Kørsler
@DynamicsAxView.StatusText(job.Status) (@job.StatusCode)@job.Caption@(job.Company ?? "–")@Display.Time(job.PlannedStartUtc)@(running is { } r ? "kører " + Minutes(r) : late is { } l ? "forsinket " + Minutes(l) : "–")@Seconds(job.MedianSeconds)@Seconds(job.P90Seconds)@job.HistoryRuns
+ } + +

STATUS-værdier fundet: @string.Join(", ", db.StatusCounts.OrderBy(s => s.Key).Select(s => $"{s.Key} = {DynamicsAxView.StatusText(AxSchemaAdapter.For(db.Version)?.BatchStatus(s.Key) ?? AxBatchStatus.Unknown)} ({s.Value})")). + @if (!AxVersions.HasUtcBatchTimes(db.Version)) + { + Oversættelsen af STATUS i @AxVersions.Text(db.Version) er ikke verificeret mod kundedata; sammenlign med batchlisten i AX. Tider gemmes af AX som lokal dato + sekunder efter midnat og vises omregnet. + } +

+ } +
+ } +} + +@if (_runs.Count > 0) +{ +
+

Afsluttede kørsler

seneste @Dage dage
+ + + + + @foreach (var run in _runs) + { + + + + + + + + + } + +
SluttedeInstans / databaseBatchjobFirmaStatusVarighed
@Display.Time(run.EndedAt)@run.InstanceName / @run.DatabaseName@(run.Caption ?? run.JobKey)@(run.Company ?? "–")@DynamicsAxView.StatusText(run.Status)@Minutes(run.Duration)
+
+} + +
+
+ Hvad betyder det? +

Reglerne SQLI-AX-001 til -004 advarer om job i fejl, job der kører meget længere end deres egne seneste kørsler, job der venter, selv om de skulle være startet, og job der har stået i Udfører i timevis. I AX 2009 og senere kommer kørslerne fra BATCHJOBHISTORY; Axapta 3.0 og AX 4.0 gemmer ingen historik, så SQLInsight bygger den selv ud fra det, den ser (og måler kørselstiden fra første gang, den så jobbet køre). Kun AX' standardtabeller og SQLInsights eget repository bruges.

+
+
+ +@code { + [SupplyParameterFromQuery] public string? Instans { get; set; } + [SupplyParameterFromQuery] public int? Dage { get; set; } + + private IReadOnlyList _views = []; + private IReadOnlyList _runs = []; + + protected override async Task OnParametersSetAsync() + { + Dage = Math.Clamp(Dage ?? 7, 1, 365); + _views = await DynamicsAxView.LoadAsync(Repository, Instans); + var now = DateTimeOffset.UtcNow; + var runs = new List(); + foreach (var view in _views) + { + runs.AddRange(await Repository.GetAxBatchRunsAsync(view.Instance.InstanceId, null, null, now.AddDays(-Dage.Value), now, 200)); + } + + _runs = runs.OrderByDescending(r => r.EndedAt).Take(200).ToList(); + } + + private static string Minutes(TimeSpan span) => span.TotalHours >= 2 ? $"{Display.Number(span.TotalHours, 1)} t" : $"{Display.Number(span.TotalMinutes)} min"; + + private static string Seconds(double? seconds) => seconds is { } s ? Minutes(TimeSpan.FromSeconds(s)) : "–"; +} diff --git a/src/SQLInsight.Web/Components/Pages/AxBlockingPage.razor b/src/SQLInsight.Web/Components/Pages/AxBlockingPage.razor new file mode 100644 index 0000000..1757d26 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/AxBlockingPage.razor @@ -0,0 +1,64 @@ +@page "/dynamics-ax/blokering" +@using SQLInsight.Core.DynamicsAx +@inject InsightRepository Repository +@inject Display Display + +AX-brugere bag blokering · SQLInsight Pro +

AX-brugere bag blokering

+

Blokering i AX-databaserne (over 5 sekunder) med den AX-bruger, der står bag den blokerende session – så vidt SQL Server kan se det.

+ +
+

Alle AX-brugere går gennem AOS'ens login, så SQL Server-login'et siger ikke, hvem det er. AX 2009 og 2012 kan skrive AX-brugeren i sessionens CONTEXT_INFO, når connectioncontext er slået til i AOS-konfigurationen. Axapta 3.0 og AX 4.0 kan ikke: der vises vært og program, og brugeren må findes under Online brugere i AX ud fra tidspunktet.

+
+ +@if (_views.Count == 0) +{ +

Ingen overvågede databaser er genkendt som Dynamics AX. Se AX-databaser.

+} +else +{ +
+

Blokering

seneste @Dage dage
+ + @if (_rows.Count == 0) + { +

Ingen blokering i AX-databaserne i perioden.

+ } + else + { + + + + @foreach (var r in _rows) + { + + + + + + + + + + + } + +
TidspunktInstans / databaseAX-brugerAX-sessionVært / programSQL-sessionBlokeredeLængste ventetid
@Display.Time(r.SampledAt)@r.Instance / @r.Database@(r.Identity.AxUser ?? "ukendt") (@r.Identity.Source)@(r.Identity.AxSessionId?.ToString() ?? "–")@r.Head.HostName / @r.Head.ProgramName@r.Head.SessionId@r.BlockedCount@Display.Number(r.MaxWaitMs / 1000.0) s
+ } +
+} + +@code { + [SupplyParameterFromQuery] public string? Instans { get; set; } + [SupplyParameterFromQuery] public int? Dage { get; set; } + + private IReadOnlyList _views = []; + private IReadOnlyList _rows = []; + + protected override async Task OnParametersSetAsync() + { + Dage = Math.Clamp(Dage ?? 7, 1, 365); + _views = await DynamicsAxView.LoadAsync(Repository, Instans); + _rows = (await DynamicsAxView.BlockingAsync(Repository, _views, DateTimeOffset.UtcNow.AddDays(-Dage.Value))).Take(500).ToList(); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/AxDatabasesPage.razor b/src/SQLInsight.Web/Components/Pages/AxDatabasesPage.razor new file mode 100644 index 0000000..f313fb9 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/AxDatabasesPage.razor @@ -0,0 +1,87 @@ +@page "/dynamics-ax/databaser" +@using SQLInsight.Core.DynamicsAx +@inject InsightRepository Repository +@inject Display Display + +AX-databaser · SQLInsight Pro +

AX-databaser

+

De overvågede databaser, som SQLInsight genkender som Microsoft Dynamics AX: version, skema, firmaer og hvilke AX-tabeller agenten må læse. Genkendelsen læser kun metadata og opdateres hver time.

+ +@if (_views.Count == 0) +{ +

Ingen overvågede databaser er genkendt som Dynamics AX endnu. Databaser vælges under Servere og databaser.

+} + +@foreach (var view in _views) +{ + foreach (var ax in view.Databases.Databases) + { + var missing = AxReadAccess.Missing(ax); +
+
+

@ax.Database på @view.Instance.Name

+ @(ax.Verified ? "Skema verificeret" : "Skema ikke verificeret") +
+ + + + + @if (ax.ModelDatabase is { } model) + { + + } + + + +
Version@ax.VersionText
Skema@AxVersions.VerificationText(ax.Version)
Tabellernes ejer@ax.Schema
Modeldatabase@model
Firmaer@(ax.Companies is { } companies ? companies.Count == 0 ? "ingen" : string.Join(", ", companies.Select(c => c.Id + (c.Name is { Length: > 0 } n ? $" ({n})" : string.Empty))) : "ukendt – " + ax.CompaniesReason)
Læsning@DynamicsAxView.IsolationText(ax.Isolation)
Genkendt@Display.Ago(view.Databases.CapturedAt.UtcDateTime)
+ @foreach (var note in ax.Notes) + { +

@note

+ } + +

AX-læseadgang (valgfri)

+ + + + @foreach (var table in AxReadAccess.Tables(ax)) + { + + } + +
TabelAgenten må læse
@ax.Schema.@table@(ax.CanRead(table) ? "ja" : "nej")
+ @if (missing.Count == 0) + { +

Agenten må læse alle de AX-tabeller, tjekkene for batchjob, nummerserier og RecId bruger. Kun SELECT; ingen kundedata.

+ } + else + { +
+

Uden læseadgang er AX-tjekkene for batchjob, nummerserier og RecId ikke målbare i @ax.Database. Adgangen er et tilvalg: kun SELECT på disse systemtabeller, ingen kundedata, ingen skriverettigheder. Kør som sysadmin eller db_owner:

+
@AxReadAccess.GrantSql(ax, ax.AgentLogin ?? view.Databases.AgentLogin, missing)
+

Det samme for enhver AX-database: db/permissions/grant-dynamics-ax-read.sql (fjernes igen med revoke-dynamics-ax-read.sql).

+
+ } +
+ } + + @foreach (var (database, problem) in view.Databases.Problems) + { +

@view.Instance.Name / @database: @problem

+ } +} + +
+
+ Hvad betyder det? +

SQLInsight genkender en AX-database på systemtabellerne SQLDICTIONARY og DATAAREA og finder versionen ud fra skemaet: 32-bit RECID (Axapta 3.0, tabeller ejet af bmssa), BATCHJOB (AX 2009), NUMBERSEQUENCESCOPE (AX 2012) og PARTITION-kolonnen med en separat modeldatabase (AX 2012 R2/R3). Kun Axapta 3.0 er verificeret mod en rigtig kundedatabase; AX 4.0, 2009, 2012 og Dynamics 365 F&O er bygget efter Microsofts dokumenterede skema og ikke verificeret mod kundedata. Hver forespørgsel tjekker først, at tabeller og kolonner findes, og viser ellers "ikke målbar" med den præcise kolonne.

+

AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning. AX-fund sendes kun til SQLInsight Hub for databaser, hvor det er slået til under Servere og databaser.

+
+
+ +@code { + [SupplyParameterFromQuery] public string? Instans { get; set; } + + private IReadOnlyList _views = []; + + protected override async Task OnParametersSetAsync() => _views = await DynamicsAxView.LoadAsync(Repository, Instans); +} diff --git a/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor b/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor new file mode 100644 index 0000000..bafad64 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor @@ -0,0 +1,91 @@ +@page "/dynamics-ax/nummerserier" +@using SQLInsight.Core.DynamicsAx +@inject InsightRepository Repository +@inject Display Display + +Nummerserier · Dynamics AX · SQLInsight Pro +

Nummerserier

+

Hvor meget af hver Dynamics AX-nummerserie der er brugt, og hvornår den løber tør ud fra SQLInsights egne daglige målinger af NEXTREC. Læses hver time; de mest brugte vises først.

+ +@if (_views.Count == 0) +{ +

Ingen overvågede databaser er genkendt som Dynamics AX. Se AX-databaser.

+} + +@foreach (var view in _views) +{ + if (view.Sequences is null) + { +

@view.Instance.Name: nummerserierne er endnu ikke læst (første gang inden for en time).

+ continue; + } + + var t = view.Sequences.Thresholds; + foreach (var db in view.Sequences.Databases) + { +
+

@db.Database på @view.Instance.Name · @AxVersions.Text(db.Version)

læst @Display.Ago(view.Sequences.CapturedAt.UtcDateTime)
+ @if (db.NumberSequencesNotMeasurable is { } reason) + { + + } + else + { + @foreach (var note in db.Notes) + { +

@note

+ } + + + + + @foreach (var s in db.NumberSequences.Take(200)) + { + var bad = !s.Manual && !s.Cyclic && !s.Blocked && (s.UsedPercent >= t.NumberSequenceWarnPercent || s.Forecast?.DaysLeft <= t.ForecastWarnDays) || s.FormatTooShort; + + + + + + + + + + + + + } + +
FirmaNummerserieTekstNæsteHøjesteFormatBrugtPr. dagDage tilbageBemærk
@(s.Company ?? "delt")@s.Code@s.Text@Display.Number(s.Next)@Display.Number(s.Highest)@s.Format@Display.Number(s.UsedPercent, 1) %@(s.Forecast is { } f ? Display.Number(f.PerDay, 1) : "–")@(s.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")@Flags(s)
+ @if (db.NumberSequences.Count > 200) + { +

Viser de 200 mest brugte af @db.NumberSequences.Count. Alle findes i API'et.

+ } + } +
+ } +} + +
+
+ Hvad betyder det? +

Brugt er (NEXTREC − Laveste) i forhold til Højeste – eller formatets kapacitet (10^antal # − 1), hvis den er mindre. Prognosen bygger på SQLInsights egne målinger (én pr. dag, gemt i repository'et); den kræver mindst et døgns historik. Manuelle og cykliske serier får ingen prognose. Reglerne SQLI-AX-005 til -008 advarer om serier, der løber tør, formater der er for korte, spærrede serier der stadig bruges, og kontinuerlige serier der ikke ryddes op.

+
+
+ +@code { + [SupplyParameterFromQuery] public string? Instans { get; set; } + + private IReadOnlyList _views = []; + + protected override async Task OnParametersSetAsync() => _views = await DynamicsAxView.LoadAsync(Repository, Instans); + + private static string Flags(AxNumberSequence s) => string.Join(", ", new[] + { + s.Blocked ? "spærret" + (s.Referenced == true ? " men i brug" : string.Empty) : null, + s.Continuous ? "kontinuerlig" : null, + s.Manual ? "manuel" : null, + s.Cyclic ? "cyklisk" : null, + s.FormatTooShort ? "format for kort" : null, + }.Where(f => f is not null)); +} diff --git a/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor b/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor new file mode 100644 index 0000000..8103afd --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor @@ -0,0 +1,61 @@ +@page "/dynamics-ax/recid" +@using SQLInsight.Core.DynamicsAx +@inject InsightRepository Repository +@inject Display Display + +RecId · Dynamics AX · SQLInsight Pro +

RecId

+

Hvor langt hver tabels RecId-serie (SYSTEMSEQUENCES) er nået, og hvornår den løber tør ud fra SQLInsights egne daglige målinger. Axapta 3.0 har 32-bit RecId (højst 2.147.483.647) og advares derfor langt tidligere end senere versioner.

+ +@if (_views.Count == 0) +{ +

Ingen overvågede databaser er genkendt som Dynamics AX. Se AX-databaser.

+} + +@foreach (var view in _views) +{ + if (view.Sequences is null) + { +

@view.Instance.Name: RecId er endnu ikke læst (første gang inden for en time).

+ continue; + } + + foreach (var db in view.Sequences.Databases) + { +
+

@db.Database på @view.Instance.Name · @AxVersions.Text(db.Version)

læst @Display.Ago(view.Sequences.CapturedAt.UtcDateTime)
+ @if (db.RecIdsNotMeasurable is { } reason) + { + + } + else + { + + + + @foreach (var r in db.RecIds.Take(100)) + { + + + + + + + + + + } + +
TabelFirmaNæste RecIdGrænseBrugtPr. dagDage tilbage
@(r.TableName ?? $"tabel-id {r.TableId}")@(r.Company ?? "–")@Display.Number(r.Next)@Display.Number(r.Max) @(r.Bits == 32 ? "(32-bit)" : "")@Display.Number(r.UsedPercent, 2) %@(r.Forecast is { } f ? Display.Number(f.PerDay) : "–")@(r.Forecast?.DaysLeft is { } d ? Display.Number(d) : "–")
+ } +
+ } +} + +@code { + [SupplyParameterFromQuery] public string? Instans { get; set; } + + private IReadOnlyList _views = []; + + protected override async Task OnParametersSetAsync() => _views = await DynamicsAxView.LoadAsync(Repository, Instans); +} diff --git a/src/SQLInsight.Web/DynamicsAxView.cs b/src/SQLInsight.Web/DynamicsAxView.cs new file mode 100644 index 0000000..0521c49 --- /dev/null +++ b/src/SQLInsight.Web/DynamicsAxView.cs @@ -0,0 +1,82 @@ +using SQLInsight.Core.Data; +using SQLInsight.Core.DynamicsAx; +using SQLInsight.Repository; + +namespace SQLInsight.Web; + +/// What the agent knows about Dynamics AX on one instance (the snapshots of the AX collectors). +public sealed record AxInstanceView(InstanceOverview Instance, AxDatabaseSnapshot Databases, AxBatchSnapshot? Batch, AxSequenceSnapshot? Sequences); + +/// An AX session that blocked others, with the AX user as far as SQL Server can tell. +public sealed record AxBlockingRow(string Instance, string Database, AxVersion Version, DateTime SampledAt, SessionInfo Head, AxSessionIdentity Identity, int BlockedCount, + long MaxWaitMs); + +/// +/// Loads the Dynamics AX pages' data, shared by SQLInsight Web (Dynamics AX in the menu) and API v1 +/// (/api/v1/dynamics-ax/…), so both show the same. +/// +public static class DynamicsAxView +{ + public const string DatabasesKind = "dynamics-ax"; + public const string BatchKind = "dynamics-ax-batch"; + public const string SequencesKind = "dynamics-ax-sequences"; + + /// The instances with recognised, monitored AX databases; one instance when is given. + public static async Task> LoadAsync(InsightRepository repository, string? instance, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + IReadOnlyList instances = instance is { Length: > 0 } + ? (await repository.GetInstanceAsync(instance, cancellationToken).ConfigureAwait(false)) is { } one ? [one] : [] + : await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false); + var result = new List(); + foreach (var i in instances) + { + var databases = await repository.GetSnapshotAsync(i.InstanceId, DatabasesKind, cancellationToken).ConfigureAwait(false); + if (databases is not { Databases.Count: > 0 }) + { + continue; + } + + result.Add(new AxInstanceView(i, databases, + await repository.GetSnapshotAsync(i.InstanceId, BatchKind, cancellationToken).ConfigureAwait(false), + await repository.GetSnapshotAsync(i.InstanceId, SequencesKind, cancellationToken).ConfigureAwait(false))); + } + + return result; + } + + /// Blocking in AX databases since , newest first, with the AX user behind each head blocker. + public static async Task> BlockingAsync(InsightRepository repository, IReadOnlyList instances, DateTimeOffset from, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(instances); + var rows = new List(); + foreach (var view in instances) + { + foreach (var e in await repository.GetBlockingAsync(view.Instance.InstanceId, from, cancellationToken).ConfigureAwait(false)) + { + var head = e.Sessions.FirstOrDefault(s => s.SessionId == e.HeadSessionId); + var database = view.Databases.Find(head?.DatabaseName) ?? e.Sessions.Select(s => view.Databases.Find(s.DatabaseName)).FirstOrDefault(d => d is not null); + if (head is null || database is null) + { + continue; + } + + rows.Add(new AxBlockingRow(view.Instance.Name, database.Database, database.Version, DateTime.SpecifyKind(e.SampledAt, DateTimeKind.Utc), head, + AxSessionMapper.Map(head, database.Version), e.BlockedCount, e.MaxWaitMs)); + } + } + + return rows.OrderByDescending(r => r.SampledAt).ToList(); + } + + public static string StatusText(AxBatchStatus status) => Rules.DynamicsAx.DynamicsAxBatchRule.StatusText(status); + + public static string IsolationText(AxReadIsolation isolation) => isolation switch + { + AxReadIsolation.Snapshot => "snapshot isolation", + AxReadIsolation.ReadCommittedSnapshot => "read committed snapshot", + _ => "read committed med 2 s lock timeout", + }; +} diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index 420da15..ebde66e 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -117,6 +117,7 @@ IntegrationEndpoints.Map(app); ApiV1Endpoints.Map(app); SupportApiEndpoints.Map(app); ConfigurationApiEndpoints.Map(app); +DynamicsAxApiEndpoints.Map(app); app.MapOpenApi("/api/v1/openapi.json").RequireRead(); Support.Map(app); InstanceSetupEndpoints.Map(app);