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

@@ -6,9 +6,11 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types;
using ScadaLink.Communication;
using ScadaLink.HealthMonitoring;
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
@@ -55,6 +57,16 @@ public class HealthPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
Services.AddSingleton(siteRepo);
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
// the existing assertions (Notification Outbox tiles, Online/Offline
// counts) keep passing; tests that target the Audit tiles set their
// own substitute.
var auditService = Substitute.For<IAuditLogQueryService>();
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
Services.AddSingleton(auditService);
var claims = new[]
{
new Claim("Username", "tester"),
@@ -92,6 +104,35 @@ public class HealthPageTests : BunitContext
Assert.Contains("View details", link.TextContent);
}
[Fact]
public void Renders_AuditKpiTiles_WithValues()
{
// Override the default empty snapshot — this test wants concrete values
// to land in the three Audit tiles.
var auditService = Substitute.For<IAuditLogQueryService>();
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AuditLogKpiSnapshot(
TotalEventsLastHour: 250,
ErrorEventsLastHour: 5,
BacklogTotal: 17,
AsOfUtc: DateTime.UtcNow)));
Services.AddSingleton(auditService);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// The three audit tiles render at the documented data-test selectors.
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
// Volume shows the formatted thousand-separator value.
Assert.Contains("250", cut.Markup);
// Backlog renders 17.
Assert.Contains("17", cut.Markup);
});
}
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{