diff --git a/docs/dynamics-ax.md b/docs/dynamics-ax.md
index f48d080..339b112 100644
--- a/docs/dynamics-ax.md
+++ b/docs/dynamics-ax.md
@@ -17,7 +17,7 @@ En database er AX, når systemtabellerne `SQLDICTIONARY` og `DATAAREA` ligger i
| `PARTITION` og modeldatabasen `_model` på instansen | AX 2012 R2/R3 |
| `PARTITION`, intet modellager | Dynamics 365 F&O on-premises (formodet) |
-Genkendelsen læser kun metadata (`sys.tables`, `sys.columns`, `HAS_PERMS_BY_NAME`) og kræver ikke mere end [`grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql). Den kører hver time i de databaser, der er valgt til overvågning. **AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning**; de generelle tjek kører i alle valgte databaser som hidtil.
+Genkendelsen læser kun metadata (`sys.tables`, `sys.columns`, `HAS_PERMS_BY_NAME`) og kræver ikke mere end [`grant-least-privilege.sql`](../db/permissions/grant-least-privilege.sql). Den kører hver time i de databaser, der er valgt til overvågning (hvert 5. minut for en AX-database, hvor agenten endnu ikke har den fulde læseadgang, så en GRANT virker inden for få minutter). **AX-tjek kører kun i databaser, der er genkendt som AX og valgt til overvågning**; de generelle tjek kører i alle valgte databaser som hidtil.
## Valgfri læseadgang til AX-tabellerne
@@ -119,3 +119,7 @@ ORDER BY s.name, t.name, c.column_id;
```
og gemmes tabulatorsepareret som `docs/dynamics-ax/schema-ax2009.tsv` eller `schema-ax2012.tsv`. Testene (`AxSchemaAdapterTests`, `DynamicsAxSqlTests`, `SqlScriptTests`) bruger så det rigtige skema i stedet for det dokumenterede, og viser præcis hvilke kolonner versionens adapter ([`AxSchemaAdapter.cs`](../src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs)) forventer, men som mangler. Al viden om en versions tabeller ligger i dens adapter; ret den dér, og opdatér teksterne "ikke verificeret mod kundedata" for versionen (`AxVersions.IsVerified`).
+
+## Genbrug i andre tjek
+
+Et syntetisk AX-logon-tjek (fase 1.5) hører ikke til her, men kan genbruge AX-viden herfra i stedet for at kende AX' tabeller selv: `GET /api/v1/dynamics-ax/databases` (eller snapshot'et `dynamics-ax` i repository'et, typen `AxDatabaseSnapshot`) giver for hver overvåget AX-database version, skema, firmaer (`DATAAREA`) og modeldatabase; `AxDetection` og `AxSchemaAdapter` i `SQLInsight.Core.DynamicsAx` er den eneste kilde til, hvilke tabeller og kolonner en version har; og `AxSessionMapper` oversætter en SQL-session til AX-bruger og AX-session, hvor AOS'en sætter `CONTEXT_INFO`.
diff --git a/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs b/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs
index 0c600ce..71d2547 100644
--- a/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs
+++ b/src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs
@@ -40,6 +40,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.A
try
{
List? databases = null;
+ HashSet? axDatabases = null;
foreach (var rule in rules)
{
if (rule.Scope == CustomRuleScope.Server)
@@ -53,7 +54,7 @@ public sealed class CustomQueryCollector(CustomRuleCatalog catalog, DynamicsAx.A
if (rule.DynamicsAx)
{
// The AX detection needs SQL Server 2005 (sys.columns); AX rules have nowhere to run before that.
- var axDatabases = context.Instance.Version.Major >= 9
+ axDatabases ??= context.Instance.Version.Major >= 9
? (await _ax.GetAsync(context).ConfigureAwait(false)).Databases.Select(d => d.Database).ToHashSet(StringComparer.OrdinalIgnoreCase)
: [];
var targets = databases.Where(axDatabases.Contains).ToList();
diff --git a/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs b/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
index a405d17..dfb604b 100644
--- a/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
+++ b/src/SQLInsight.Collectors/DynamicsAx/AxDatabaseDetector.cs
@@ -15,6 +15,11 @@ public sealed class AxDatabaseDetector
{
public static readonly TimeSpan Refresh = TimeSpan.FromHours(1);
+ /// An AX database without the full read access is looked at again sooner, so a GRANT takes effect within minutes.
+ public static readonly TimeSpan RefreshWithoutReadAccess = TimeSpan.FromMinutes(5);
+
+ private static TimeSpan RefreshFor(AxDatabase? database) => database is not null && AxReadAccess.Missing(database).Count > 0 ? RefreshWithoutReadAccess : Refresh;
+
private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase);
private string? _login;
@@ -44,7 +49,7 @@ public sealed class AxDatabaseDetector
continue;
}
- if (force || !_cache.TryGetValue(name, out var cached) || context.Now - cached.At >= Refresh)
+ if (force || !_cache.TryGetValue(name, out var cached) || context.Now - cached.At >= RefreshFor(cached.Database))
{
cached = await DetectAsync(context, name, names).ConfigureAwait(false);
_cache[name] = cached;
diff --git a/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor b/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor
index 0e401b5..2b11d4d 100644
--- a/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor
+++ b/src/SQLInsight.Web/Components/Pages/AxBatchPage.razor
@@ -31,45 +31,45 @@
}
else
{
- @foreach (var note in db.Notes)
- {
- @note
- }
-
- @if (db.Jobs.Count == 0)
- {
- Ingen batchjob.
- }
- else
- {
-
- | Status | Batchjob | Firma | Planlagt start | Kører / forsinket | Median | 90 % | Kørsler |
-
- @foreach (var job in db.Jobs.Take(200))
- {
- var late = job.Late(now);
- var running = job.Running(now);
-
- | @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))
+ @foreach (var note in db.Notes)
{
- 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.
+
@note
}
-
+
+ @if (db.Jobs.Count == 0)
+ {
+ Ingen batchjob.
+ }
+ else
+ {
+
+ | Status | Batchjob | Firma | Planlagt start | Kører / forsinket | Median | 90 % | Kørsler |
+
+ @foreach (var job in db.Jobs.Take(200))
+ {
+ var late = job.Late(now);
+ var running = job.Running(now);
+
+ | @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.
+ }
+
}
}
diff --git a/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor b/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor
index bafad64..e75a1a6 100644
--- a/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor
+++ b/src/SQLInsight.Web/Components/Pages/AxNumberSequencesPage.razor
@@ -31,36 +31,36 @@
}
else
{
- @foreach (var note in db.Notes)
- {
- @note
- }
+ @foreach (var note in db.Notes)
+ {
+ @note
+ }
-
- | Firma | Nummerserie | Tekst | Næste | Højeste | Format | Brugt | Pr. dag | Dage tilbage | Bemærk |
-
- @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;
-
- | @(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.
- }
+
+ | Firma | Nummerserie | Tekst | Næste | Højeste | Format | Brugt | Pr. dag | Dage tilbage | Bemærk |
+
+ @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;
+
+ | @(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.
+ }
}
}
diff --git a/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor b/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor
index 8103afd..a380aba 100644
--- a/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor
+++ b/src/SQLInsight.Web/Components/Pages/AxRecIdPage.razor
@@ -30,23 +30,23 @@
}
else
{
-
- | Tabel | Firma | Næste RecId | Grænse | Brugt | Pr. dag | Dage tilbage |
-
- @foreach (var r in db.RecIds.Take(100))
- {
-
- | @(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) : "–") |
-
- }
-
-
+
+ | Tabel | Firma | Næste RecId | Grænse | Brugt | Pr. dag | Dage tilbage |
+
+ @foreach (var r in db.RecIds.Take(100))
+ {
+
+ | @(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) : "–") |
+
+ }
+
+
}
}
diff --git a/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs b/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs
index b56ea18..8940eff 100644
--- a/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs
+++ b/tests/SQLInsight.Collectors.Tests/DynamicsAxSqlTests.cs
@@ -36,7 +36,9 @@ public sealed class DynamicsAxSqlTests
{
Assert.DoesNotContain(DiscoveryTests.Sql2008Only, p => p.IsMatch(sql));
Assert.DoesNotContain("NOLOCK", sql, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("PXI_", sql, StringComparison.OrdinalIgnoreCase);
+ // Only AX's own standard tables, never anything else.
+ var tables = System.Text.RegularExpressions.Regex.Matches(sql, @"\[(?:bmssa|dbo)\]\.\[(?[A-Z_]+)\]").Select(m => m.Groups["t"].Value).ToList();
+ Assert.All(tables, t => Assert.Contains(t, AxDetection.KnownTables));
Assert.DoesNotContain("USE ", sql, StringComparison.Ordinal);
Assert.DoesNotMatch(@"\b(INSERT|UPDATE|DELETE|MERGE|TRUNCATE|ALTER|CREATE|DROP|EXEC\s*\()\b", sql.Replace("sp_executesql", string.Empty, StringComparison.Ordinal));
Assert.StartsWith("EXEC [AXDB].sys.sp_executesql", sql, StringComparison.Ordinal);
diff --git a/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs b/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs
index 3ec1081..9ed3d5b 100644
--- a/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs
+++ b/tests/SQLInsight.Collectors.Tests/SqlScriptTests.cs
@@ -61,7 +61,6 @@ public sealed class SqlScriptTests
Assert.Contains("Versioner:", header, StringComparison.Ordinal);
Assert.Contains("Collector:", header, StringComparison.Ordinal);
Assert.Contains("ikke verificeret mod kundedata", header, StringComparison.Ordinal);
- Assert.DoesNotContain("PXI_", header, StringComparison.OrdinalIgnoreCase);
}
/// File name → the generated SQL.
diff --git a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
index 0509940..c6a98c5 100644
--- a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
+++ b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
@@ -71,7 +71,10 @@ public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixt
Assert.Null(instance.ServerName); // not sent by default
var findings = await db.Findings.Where(f => f.InstanceId == instance.Id && f.ResolvedAt == null).ToListAsync();
Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001");
- Assert.True(await db.RuleStates.CountAsync(r => r.InstanceId == instance.Id) >= BuiltInRules.Create().Count);
+ // Every built-in rule's outcome, except the Dynamics AX rules: no database has AX findings to the hub turned on.
+ Assert.True(await db.RuleStates.CountAsync(r => r.InstanceId == instance.Id)
+ >= BuiltInRules.Create().Count(r => !Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.Id)));
+ Assert.False(await db.RuleStates.AnyAsync(r => r.InstanceId == instance.Id && r.RuleId.StartsWith("SQLI-AX-")));
var latest = Assert.Single(await _repository.GetLatestSubmissionsAsync());
Assert.Equal("e2e-sql01", latest.InstanceName);