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

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.ConfigurationDatabase.Repositories;
@@ -421,4 +422,117 @@ VALUES
return results;
}
/// <summary>
/// M7-T13 Bundle E — Health-dashboard Audit KPI tiles aggregate query.
/// Single round-trip
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE WHEN Status IN (...) THEN 1 ELSE 0 END) AS Errors</c>)
/// over the trailing <paramref name="window"/> anchored at
/// <paramref name="nowUtc"/>. Returns a snapshot with
/// <see cref="AuditLogKpiSnapshot.BacklogTotal"/> left at zero — the service
/// layer composes that in from
/// <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>.
/// </summary>
/// <remarks>
/// <para>
/// Why one query, not two: keeping the numerator + denominator in the same
/// scan means the error rate the UI displays is computed from a consistent
/// snapshot. With two separate queries a row could be inserted between
/// them, inflating the denominator past the numerator (or vice-versa) and
/// briefly producing a misleading percentage.
/// </para>
/// <para>
/// "Error" rows are <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c> — see
/// <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/> for the rationale.
/// We pass the three discriminator strings as separate parameters rather
/// than building an IN-list to keep the prepared statement cache-friendly.
/// </para>
/// </remarks>
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default)
{
var anchorUtc = (nowUtc ?? DateTime.UtcNow).ToUniversalTime();
var thresholdUtc = anchorUtc - window;
// ExecuteSqlInterpolated parameterises every interpolation — the enum
// discriminators are passed as varchar parameters that match the
// varchar(32) Status column (HasConversion<string>()).
var failedStr = nameof(Commons.Types.Enums.AuditStatus.Failed);
var parkedStr = nameof(Commons.Types.Enums.AuditStatus.Parked);
var discardedStr = nameof(Commons.Types.Enums.AuditStatus.Discarded);
long total = 0;
long errors = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using var cmd = conn.CreateCommand();
// Named parameters keep the prepared statement cache stable across
// calls — only the values change. COUNT_BIG returns a bigint so
// we read into long even when the running total fits in int.
cmd.CommandText = @"
SELECT
COUNT_BIG(*) AS Total,
SUM(CASE WHEN Status IN (@failed, @parked, @discarded) THEN 1 ELSE 0 END) AS Errors
FROM dbo.AuditLog
WHERE OccurredAtUtc >= @threshold
AND OccurredAtUtc <= @anchor;";
var pThreshold = cmd.CreateParameter();
pThreshold.ParameterName = "@threshold";
pThreshold.Value = thresholdUtc;
cmd.Parameters.Add(pThreshold);
var pAnchor = cmd.CreateParameter();
pAnchor.ParameterName = "@anchor";
pAnchor.Value = anchorUtc;
cmd.Parameters.Add(pAnchor);
var pFailed = cmd.CreateParameter();
pFailed.ParameterName = "@failed";
pFailed.Value = failedStr;
cmd.Parameters.Add(pFailed);
var pParked = cmd.CreateParameter();
pParked.ParameterName = "@parked";
pParked.Value = parkedStr;
cmd.Parameters.Add(pParked);
var pDiscarded = cmd.CreateParameter();
pDiscarded.ParameterName = "@discarded";
pDiscarded.Value = discardedStr;
cmd.Parameters.Add(pDiscarded);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
// SUM over an empty set is NULL; COUNT_BIG over an empty set is 0.
total = reader.IsDBNull(0) ? 0L : reader.GetInt64(0);
errors = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1));
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return new AuditLogKpiSnapshot(
TotalEventsLastHour: total,
ErrorEventsLastHour: errors,
BacklogTotal: 0L,
AsOfUtc: anchorUtc);
}
}