feat(ui): Audit KPI tiles on Health dashboard (#23 M7)

Adds three KPI tiles to the central Health dashboard for the Audit channel:
volume (rows in the last hour), error rate (Failed/Parked/Discarded over
total), and backlog (sum of SiteAuditBacklog.PendingCount across all sites).

Repo + service:
- IAuditLogRepository.GetKpiSnapshotAsync(window, nowUtc) — single aggregate
  SELECT over the trailing window returning total + error counts; nowUtc is
  optional for production callers and pinned by integration tests against the
  shared MSSQL fixture so the global counts are deterministic.
- AuditLogQueryService.GetKpiSnapshotAsync() — composes the repo aggregate
  with a sum of SiteAuditBacklog.PendingCount read from ICentralHealthAggregator.
- AuditLogKpiSnapshot record in Commons/Types/.

UI:
- New AuditKpiTiles Blazor component (Components/Health/) — three Bootstrap
  card-tiles, click navigates to /audit/log with the matching pre-filter.
- Health.razor wires the tiles in alongside the existing Notification Outbox
  KPIs; LoadAuditKpis() runs on every 10s refresh tick and degrades to em
  dashes + inline error if the query fails.
- AuditLogPage extended to parse ?status= so the error-rate tile drill-in
  (?status=Failed) auto-loads the grid.

Tests:
- AuditLogRepositoryTests: GetKpiSnapshotAsync mixed-status + empty-window
  cases against the MSSQL migration fixture.
- AuditLogQueryServiceTests: forwarding + backlog composition; sites with
  null SiteAuditBacklog contribute zero.
- AuditKpiTilesTests: 9 bUnit tests covering tile render, error-rate maths
  with safe zero-events handling, em-dash unavailable path, click-through
  navigation, and warning/danger border thresholds.
- HealthPageTests: new Renders_AuditKpiTiles_WithValues plus IAuditLogQueryService
  stub registration in the constructor so existing outbox tests still pass.
- AuditLogPageScaffoldTests: ?status=Failed auto-load + unknown status drop.
This commit is contained in:
Joseph Doherty
2026-05-20 20:43:57 -04:00
parent 38fc9b4102
commit 943c2ced39
18 changed files with 969 additions and 7 deletions

View File

@@ -510,6 +510,82 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
}
// ------------------------------------------------------------------------
// M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
// ------------------------------------------------------------------------
//
// The dashboard's "Audit volume" tile reads TotalEventsLastHour and the
// "Audit error rate" tile reads ErrorEventsLastHour / TotalEventsLastHour.
// The repository must (a) count rows whose OccurredAtUtc falls in
// [nowUtc - window, nowUtc] and (b) within that scope count rows whose
// Status ∈ {Failed, Parked, Discarded} as "error". BacklogTotal is left at
// zero here — the service layer composes it in from the health aggregator.
//
// To keep the test deterministic against the shared fixture DB, each test
// pins an obscure-distant nowUtc and seeds rows with OccurredAtUtc inside a
// narrow band centred on that anchor — no other test in this class seeds
// there, so the global count equals the seeded count for that band.
[SkippableFact]
public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// Anchor in November 2026 — no other test in this class seeds there.
var nowUtc = new DateTime(2026, 11, 20, 10, 0, 0, DateTimeKind.Utc);
// Seed 3 success + 1 Failed + 1 Parked + 1 Discarded inside the trailing
// 1h window; plus 1 row outside the window that must be excluded.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-5), status: AuditStatus.Delivered));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-10), status: AuditStatus.Delivered));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-15), status: AuditStatus.Delivered));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-20), status: AuditStatus.Failed));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-25), status: AuditStatus.Parked));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-30), status: AuditStatus.Discarded));
// Outside-window row (2h before nowUtc).
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddHours(-2), status: AuditStatus.Failed));
// Submitted is in-flight, not an "error" — must NOT count toward errors.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-2), status: AuditStatus.Submitted));
var snapshot = await repo.GetKpiSnapshotAsync(
window: TimeSpan.FromHours(1),
nowUtc: nowUtc);
// 7 rows fall in the trailing 1h window (3 Delivered + 1 Failed + 1 Parked + 1 Discarded + 1 Submitted).
// The 2h-before-nowUtc Failed row is excluded by the window.
Assert.Equal(7, snapshot.TotalEventsLastHour);
// Only Failed/Parked/Discarded count as errors → 3.
Assert.Equal(3, snapshot.ErrorEventsLastHour);
// The service layer fills BacklogTotal; the repo leaves it at 0.
Assert.Equal(0, snapshot.BacklogTotal);
// AsOfUtc echoes the anchor.
Assert.Equal(nowUtc, snapshot.AsOfUtc);
}
[SkippableFact]
public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// Anchor in December 2026 — no test seeds there, so the window is empty.
var nowUtc = new DateTime(2026, 12, 20, 10, 0, 0, DateTimeKind.Utc);
var snapshot = await repo.GetKpiSnapshotAsync(
window: TimeSpan.FromMinutes(1),
nowUtc: nowUtc);
Assert.Equal(0, snapshot.TotalEventsLastHour);
Assert.Equal(0, snapshot.ErrorEventsLastHour);
Assert.Equal(0, snapshot.BacklogTotal);
Assert.Equal(nowUtc, snapshot.AsOfUtc);
}
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
{
var conn = context.Database.GetDbConnection();