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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user