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

@@ -1,4 +1,5 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.Commons.Interfaces.Repositories;
@@ -87,4 +88,50 @@ public interface IAuditLogRepository
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default);
/// <summary>
/// Audit Log (#23) M7 Bundle E (T13) — returns aggregate counts over the
/// trailing <paramref name="window"/> driving the central Health
/// dashboard's Audit KPI tiles.
/// </summary>
/// <param name="window">
/// Trailing time window (e.g. <c>TimeSpan.FromHours(1)</c>). Rows whose
/// <c>OccurredAtUtc &gt;= nowUtc - window</c> are counted; the upper
/// bound is <paramref name="nowUtc"/>.
/// </param>
/// <param name="nowUtc">
/// Optional explicit "now" timestamp used to anchor the trailing window.
/// Defaults to <see cref="DateTime.UtcNow"/> at call time when null —
/// production callers should leave this null; tests pin a deterministic
/// value so the window is reproducible across runs.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A snapshot with <c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c>
/// populated; <c>BacklogTotal</c> is left at zero (this method has no
/// visibility into per-site backlogs — the service layer composes it in
/// from <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>).
/// <c>AsOfUtc</c> is set to the server-side <c>UtcNow</c> at the time of
/// the query.
/// </returns>
/// <remarks>
/// <para>
/// Implemented as a single aggregate query
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors</c>) rather than
/// two round trips so the volume + error rate tiles read a consistent
/// snapshot — the denominator and numerator come from the same scan.
/// </para>
/// <para>
/// Errors are defined as <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Failed"/>,
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Parked"/>, or
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Discarded"/>
/// — every non-success terminal lifecycle state. <c>Submitted</c>,
/// <c>Forwarded</c>, <c>Attempted</c> are in-flight and are NOT errors;
/// <c>Delivered</c> is success; <c>Skipped</c> is an intentional no-op.
/// </para>
/// </remarks>
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,38 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Audit Log (#23) M7 Bundle E (T13) — point-in-time KPI snapshot for the central
/// Health dashboard's "Audit" tile group. Aggregates volume + error counts over
/// the trailing window from the central <c>AuditLog</c> table and combines them
/// with the global pending backlog summed across every site's
/// <see cref="SiteAuditBacklogSnapshot"/>.
/// </summary>
/// <param name="TotalEventsLastHour">
/// Total <c>AuditLog</c> rows whose <c>OccurredAtUtc</c> falls inside the trailing
/// 1-hour window. Drives the "Audit volume" tile and the denominator of
/// "Audit error rate". A zero value renders as "0" rather than an em dash —
/// "zero rows in the last hour" is a real, valid signal in a quiet system.
/// </param>
/// <param name="ErrorEventsLastHour">
/// Total <c>AuditLog</c> rows in the same window whose <see cref="Enums.AuditStatus"/>
/// is <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c>. Drives the "Audit error
/// rate" tile numerator; clicking the tile drills in to <c>/audit/log</c>
/// pre-filtered on one of those statuses.
/// </param>
/// <param name="BacklogTotal">
/// Sum of <c>SiteAuditBacklog.PendingCount</c> across every site's latest
/// <see cref="ScadaLink.Commons.Messages.Health.SiteHealthReport"/>. Sites whose
/// snapshot is <c>null</c> (no report yet, or reporter not running) contribute
/// zero. A persistently non-zero value across multiple refresh ticks indicates
/// the site→central drain isn't keeping up.
/// </param>
/// <param name="AsOfUtc">
/// UTC timestamp at which the snapshot was computed. Used by the UI to label
/// "as of HH:mm:ss" beneath the tile group and to detect stale data when a
/// refresh tick fails.
/// </param>
public sealed record AuditLogKpiSnapshot(
long TotalEventsLastHour,
long ErrorEventsLastHour,
long BacklogTotal,
DateTime AsOfUtc);