@@ -56,6 +60,12 @@
Notification Outbox KPIs unavailable: @_outboxKpiError
}
+ @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
+ (volume / error rate / backlog). Refreshed alongside the site states. *@
+
+
@if (_siteStates.Count == 0)
{
No site health reports received yet.
@@ -347,6 +357,13 @@
private bool _outboxKpiAvailable;
private string? _outboxKpiError;
+ // Audit Log (#23) M7 Bundle E — Audit KPI tiles. Volume + error rate come
+ // from a 1h aggregate over the central AuditLog table; backlog sums the
+ // per-site SiteAuditBacklog.PendingCount via the health aggregator.
+ private AuditLogKpiSnapshot? _auditKpi;
+ private bool _auditKpiAvailable;
+ private string? _auditKpiError;
+
private static bool SiteHasActiveErrors(SiteHealthState state)
{
var report = state.LatestReport;
@@ -384,6 +401,7 @@
{
_siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis();
+ await LoadAuditKpis();
}
private async Task LoadOutboxKpis()
@@ -416,6 +434,24 @@
private string OutboxTileValue(int value) =>
_outboxKpiAvailable ? value.ToString() : "—";
+ // Audit KPI loader: wraps the service call so a transient DB outage degrades
+ // the three tiles to em dashes with an inline error rather than killing the
+ // dashboard. Mirrors LoadOutboxKpis's error handling shape.
+ private async Task LoadAuditKpis()
+ {
+ try
+ {
+ _auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync();
+ _auditKpiAvailable = true;
+ _auditKpiError = null;
+ }
+ catch (Exception ex)
+ {
+ _auditKpiAvailable = false;
+ _auditKpiError = $"KPI query failed: {ex.Message}";
+ }
+ }
+
private string GetSiteName(string siteId)
{
return _siteNames.GetValueOrDefault(siteId, siteId);
diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
index 971960e..6a566b3 100644
--- a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
+++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
@@ -1,6 +1,8 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
+using ScadaLink.HealthMonitoring;
namespace ScadaLink.CentralUI.Services;
@@ -11,11 +13,21 @@ namespace ScadaLink.CentralUI.Services;
///
public sealed class AuditLogQueryService : IAuditLogQueryService
{
- private readonly IAuditLogRepository _repository;
+ // M7 Bundle E (T13): trailing window for the Health dashboard's Audit KPI tiles.
+ // Hard-coded here rather than configurable because the requirement
+ // (Component-AuditLog.md §"Health & KPIs") fixes "rows/min over the last hour"
+ // and "% errors over the last hour" as the KPI definition.
+ private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
- public AuditLogQueryService(IAuditLogRepository repository)
+ private readonly IAuditLogRepository _repository;
+ private readonly ICentralHealthAggregator _healthAggregator;
+
+ public AuditLogQueryService(
+ IAuditLogRepository repository,
+ ICentralHealthAggregator healthAggregator)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
}
public int DefaultPageSize => 100;
@@ -29,4 +41,29 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
return _repository.QueryAsync(filter, effective, ct);
}
+
+ ///
+ public async Task
GetKpiSnapshotAsync(CancellationToken ct = default)
+ {
+ // 1. Volume + error counts: aggregate over the trailing 1h window.
+ // BacklogTotal is left at 0 by the repository — we fill it from the
+ // in-memory health aggregator below.
+ var repoSnapshot = await _repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
+
+ // 2. Backlog: sum PendingCount across every site's latest report.
+ // Sites that have not yet reported or whose reporter is disabled
+ // leave SiteAuditBacklog null — those contribute zero (a Missing
+ // snapshot is "unknown", not "zero", but the tile is best-effort).
+ long backlog = 0;
+ foreach (var state in _healthAggregator.GetAllSiteStates().Values)
+ {
+ var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount;
+ if (pending is > 0)
+ {
+ backlog += pending.Value;
+ }
+ }
+
+ return repoSnapshot with { BacklogTotal = backlog };
+ }
}
diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
index b9236f9..08b85d8 100644
--- a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
+++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
@@ -1,4 +1,5 @@
using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Services;
@@ -27,4 +28,26 @@ public interface IAuditLogQueryService
/// Default page size when callers don't specify one.
int DefaultPageSize { get; }
+
+ ///
+ /// Audit Log (#23) M7 Bundle E (T13) — returns the point-in-time KPI snapshot
+ /// the Health dashboard's Audit tiles render. Composes:
+ ///
+ /// - TotalEventsLastHour + ErrorEventsLastHour from
+ ///
+ /// (1-hour trailing window).
+ /// - BacklogTotal from the sum of every site's
+ /// SiteHealthReport.SiteAuditBacklog.PendingCount via
+ /// .
+ ///
+ ///
+ ///
+ /// Repository + aggregator are read independently; if either source has no
+ /// data the corresponding field is zero (a real signal — "no events" vs
+ /// "no backlog" — rather than an error). The service does NOT swallow
+ /// exceptions; the page wraps the call in a try/catch so a transient DB
+ /// outage degrades the tile group to "unavailable" rather than killing the
+ /// dashboard.
+ ///
+ Task GetKpiSnapshotAsync(CancellationToken ct = default);
}
diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index bcda482..36b0d0f 100644
--- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -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> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default);
+
+ ///
+ /// Audit Log (#23) M7 Bundle E (T13) — returns aggregate counts over the
+ /// trailing driving the central Health
+ /// dashboard's Audit KPI tiles.
+ ///
+ ///
+ /// Trailing time window (e.g. TimeSpan.FromHours(1)). Rows whose
+ /// OccurredAtUtc >= nowUtc - window are counted; the upper
+ /// bound is .
+ ///
+ ///
+ /// Optional explicit "now" timestamp used to anchor the trailing window.
+ /// Defaults to at call time when null —
+ /// production callers should leave this null; tests pin a deterministic
+ /// value so the window is reproducible across runs.
+ ///
+ /// Cancellation token.
+ ///
+ /// A snapshot with TotalEventsLastHour + ErrorEventsLastHour
+ /// populated; BacklogTotal is left at zero (this method has no
+ /// visibility into per-site backlogs — the service layer composes it in
+ /// from ).
+ /// AsOfUtc is set to the server-side UtcNow at the time of
+ /// the query.
+ ///
+ ///
+ ///
+ /// Implemented as a single aggregate query
+ /// (SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors) rather than
+ /// two round trips so the volume + error rate tiles read a consistent
+ /// snapshot — the denominator and numerator come from the same scan.
+ ///
+ ///
+ /// Errors are defined as ,
+ /// , or
+ ///
+ /// — every non-success terminal lifecycle state. Submitted,
+ /// Forwarded, Attempted are in-flight and are NOT errors;
+ /// Delivered is success; Skipped is an intentional no-op.
+ ///
+ ///
+ Task GetKpiSnapshotAsync(
+ TimeSpan window,
+ DateTime? nowUtc = null,
+ CancellationToken ct = default);
}
diff --git a/src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs b/src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs
new file mode 100644
index 0000000..83cd17a
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs
@@ -0,0 +1,38 @@
+namespace ScadaLink.Commons.Types;
+
+///
+/// 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 AuditLog table and combines them
+/// with the global pending backlog summed across every site's
+/// .
+///
+///
+/// Total AuditLog rows whose OccurredAtUtc 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.
+///
+///
+/// Total AuditLog rows in the same window whose
+/// is Failed, Parked, or Discarded. Drives the "Audit error
+/// rate" tile numerator; clicking the tile drills in to /audit/log
+/// pre-filtered on one of those statuses.
+///
+///
+/// Sum of SiteAuditBacklog.PendingCount across every site's latest
+/// . Sites whose
+/// snapshot is null (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.
+///
+///
+/// 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.
+///
+public sealed record AuditLogKpiSnapshot(
+ long TotalEventsLastHour,
+ long ErrorEventsLastHour,
+ long BacklogTotal,
+ DateTime AsOfUtc);
diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index d2d74ac..f517a8e 100644
--- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -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;
}
+
+ ///
+ /// M7-T13 Bundle E — Health-dashboard Audit KPI tiles aggregate query.
+ /// Single round-trip
+ /// (SELECT COUNT_BIG(*) AS Total, SUM(CASE WHEN Status IN (...) THEN 1 ELSE 0 END) AS Errors)
+ /// over the trailing anchored at
+ /// . Returns a snapshot with
+ /// left at zero — the service
+ /// layer composes that in from
+ /// .
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// "Error" rows are Failed, Parked, or Discarded — see
+ /// 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.
+ ///
+ ///
+ public async Task 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()).
+ 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);
+ }
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
index 724ae68..51a0bb7 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
@@ -220,5 +220,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
+
+ public Task GetKpiSnapshotAsync(
+ TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
+ _inner.GetKpiSnapshotAsync(window, nowUtc, ct);
}
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
index afa20bf..241b720 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
@@ -78,6 +78,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture>(Boundaries.ToArray());
}
+
+ public Task GetKpiSnapshotAsync(
+ TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
+ Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
index 32b0a9a..b4d3569 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
@@ -48,6 +48,9 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult>(Array.Empty());
+ public Task GetKpiSnapshotAsync(
+ TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
+ Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
}
///
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
index 5cbcfe9..87b5024 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
@@ -93,6 +93,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult>(Array.Empty());
+
+ public Task GetKpiSnapshotAsync(
+ TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
+ Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
}
///
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs
new file mode 100644
index 0000000..2c47dc8
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs
@@ -0,0 +1,158 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.DependencyInjection;
+using ScadaLink.CentralUI.Components.Health;
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.CentralUI.Tests.Components.Health;
+
+///
+/// bUnit tests for (#23 M7 Bundle E / M7-T13). The
+/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
+/// from a single . The tests pin:
+///
+///
+/// - Three-tile render contract (data-test attributes for stable selectors).
+/// - Error-rate maths: ErrorEventsLastHour / TotalEventsLastHour with
+/// safe zero-events handling (no DivideByZero, displays "0.0%").
+/// - Unavailable snapshot renders em dashes plus the error message.
+/// - Tile clicks navigate to the correct pre-filtered Audit Log URL.
+///
+///
+public class AuditKpiTilesTests : BunitContext
+{
+ private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) =>
+ new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
+
+ [Fact]
+ public void Renders_ThreeTiles_FromSnapshot()
+ {
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 120, errors: 3, backlog: 7))
+ .Add(c => c.IsAvailable, true));
+
+ // Three stable data-test selectors — these are the contract for both
+ // tests and any future Playwright sweep.
+ 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);
+
+ // Tile values render the snapshot's counters.
+ Assert.Contains("120", cut.Markup); // volume
+ Assert.Contains("7", cut.Markup); // backlog
+ }
+
+ [Fact]
+ public void ErrorRate_Computed_From_Total_AndErrors()
+ {
+ // 5 errors out of 100 → 5.0%.
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
+ .Add(c => c.IsAvailable, true));
+
+ Assert.Contains("5.0%", cut.Markup);
+ }
+
+ [Fact]
+ public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent()
+ {
+ // Total = 0 → naïve division would throw or yield NaN. The tile must
+ // render "0.0%" instead (zero events means zero errors too — a real
+ // signal, not an unavailability marker).
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 0, errors: 0, backlog: 0))
+ .Add(c => c.IsAvailable, true));
+
+ Assert.Contains("0.0%", cut.Markup);
+ // And the volume tile shows "0", not an em dash — the snapshot itself
+ // is available; the system was just quiet for the hour.
+ Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
+ }
+
+ [Fact]
+ public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
+ {
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, (AuditLogKpiSnapshot?)null)
+ .Add(c => c.IsAvailable, false)
+ .Add(c => c.ErrorMessage, "DB connection refused"));
+
+ // All three tiles show em dashes — em dash (U+2014) "—" must appear.
+ Assert.Contains("—", cut.Markup);
+ // Inline error message renders below.
+ Assert.Contains("Audit KPIs unavailable", cut.Markup);
+ Assert.Contains("DB connection refused", cut.Markup);
+ }
+
+ [Fact]
+ public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter()
+ {
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
+ .Add(c => c.IsAvailable, true));
+
+ // bUnit's BunitNavigationManager records the last URI a Navigation.NavigateTo call hit.
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+
+ var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
+ tile.Click();
+
+ // Spec: error-rate tile drills into ?status=Failed.
+ Assert.Contains("/audit/log?status=Failed", nav.Uri);
+ }
+
+ [Fact]
+ public void VolumeTile_Click_NavigatesToUnfilteredAuditLog()
+ {
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
+ .Add(c => c.IsAvailable, true));
+
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ var tile = cut.Find("[data-test=\"audit-kpi-volume\"]");
+ tile.Click();
+
+ // Unfiltered /audit/log — no query string.
+ Assert.EndsWith("/audit/log", nav.Uri);
+ }
+
+ [Fact]
+ public void BacklogTile_Click_NavigatesToAuditLog()
+ {
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
+ .Add(c => c.IsAvailable, true));
+
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ var tile = cut.Find("[data-test=\"audit-kpi-backlog\"]");
+ tile.Click();
+
+ Assert.EndsWith("/audit/log", nav.Uri);
+ }
+
+ [Fact]
+ public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent()
+ {
+ // 5% is < 10% → warning border, not danger.
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
+ .Add(c => c.IsAvailable, true));
+
+ var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
+ Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
+ Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
+ }
+
+ [Fact]
+ public void HighErrorRate_GetsDangerBorder()
+ {
+ // 25% is > 10% → danger border.
+ var cut = Render(p => p
+ .Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 25, backlog: 0))
+ .Add(c => c.IsAvailable, true));
+
+ var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
+ Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
index 9852eb9..01de979 100644
--- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
@@ -191,6 +191,44 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
+ [Fact]
+ public void NavigateWithStatusParam_AppliesStatusFilter()
+ {
+ // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
+ // in with ?status=Failed. The page parses the enum (case-insensitive),
+ // builds an AuditLogQueryFilter with Status set, and auto-loads.
+ _queryService = Substitute.For();
+ _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
+
+ cut.WaitForAssertion(() =>
+ {
+ _queryService.Received().QueryAsync(
+ Arg.Is(f => f.Status == AuditStatus.Failed),
+ Arg.Any(),
+ Arg.Any());
+ });
+ }
+
+ [Fact]
+ public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
+ {
+ _queryService = Substitute.For();
+
+ var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin");
+
+ // An unparseable status value leaves Status null. With no other filter
+ // params present the page renders but does NOT call the query service
+ // (matching the existing "no params" contract).
+ cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
+ _queryService.DidNotReceive().QueryAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
[Fact]
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
index a78b9b0..78dbd52 100644
--- a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
@@ -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>(new List()));
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();
+ auditService.GetKpiSnapshotAsync(Arg.Any())
+ .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();
+ auditService.GetKpiSnapshotAsync(Arg.Any())
+ .Returns(Task.FromResult(new AuditLogKpiSnapshot(
+ TotalEventsLastHour: 250,
+ ErrorEventsLastHour: 5,
+ BacklogTotal: 17,
+ AsOfUtc: DateTime.UtcNow)));
+ Services.AddSingleton(auditService);
+
+ var cut = Render();
+
+ 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()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
index 97743bf..181f3bc 100644
--- a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
@@ -2,8 +2,11 @@ using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Messages.Health;
+using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
+using ScadaLink.HealthMonitoring;
namespace ScadaLink.CentralUI.Tests.Services;
@@ -15,6 +18,13 @@ namespace ScadaLink.CentralUI.Tests.Services;
///
public class AuditLogQueryServiceTests
{
+ private static ICentralHealthAggregator EmptyAggregator()
+ {
+ var agg = Substitute.For();
+ agg.GetAllSiteStates().Returns(new Dictionary());
+ return agg;
+ }
+
[Fact]
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
@@ -28,7 +38,7 @@ public class AuditLogQueryServiceTests
repo.QueryAsync(filter, paging, Arg.Any())
.Returns(Task.FromResult>(expected));
- var sut = new AuditLogQueryService(repo);
+ var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.QueryAsync(filter, paging);
@@ -44,7 +54,7 @@ public class AuditLogQueryServiceTests
repo.QueryAsync(Arg.Any(), Arg.Do(p => observed = p), Arg.Any())
.Returns(Task.FromResult>(Array.Empty()));
- var sut = new AuditLogQueryService(repo);
+ var sut = new AuditLogQueryService(repo, EmptyAggregator());
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
@@ -54,4 +64,103 @@ public class AuditLogQueryServiceTests
Assert.Null(observed.AfterOccurredAtUtc);
Assert.Null(observed.AfterEventId);
}
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
+ {
+ var repo = Substitute.For();
+ var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
+ var repoSnapshot = new AuditLogKpiSnapshot(
+ TotalEventsLastHour: 42,
+ ErrorEventsLastHour: 7,
+ BacklogTotal: 0, // repo leaves this at zero
+ AsOfUtc: anchor);
+ repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(repoSnapshot));
+
+ // Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11.
+ // Sum = 16 → backlog tile shows 16.
+ var sites = new Dictionary
+ {
+ ["plant-a"] = StateWithBacklog("plant-a", pending: 5),
+ ["plant-b"] = StateWithBacklog("plant-b", pending: 11),
+ };
+ var agg = Substitute.For();
+ agg.GetAllSiteStates().Returns(sites);
+
+ var sut = new AuditLogQueryService(repo, agg);
+
+ var snapshot = await sut.GetKpiSnapshotAsync();
+
+ Assert.Equal(42, snapshot.TotalEventsLastHour);
+ Assert.Equal(7, snapshot.ErrorEventsLastHour);
+ Assert.Equal(16, snapshot.BacklogTotal);
+ Assert.Equal(anchor, snapshot.AsOfUtc);
+
+ // The service requests a 1-hour trailing window and lets the repo
+ // anchor nowUtc to its own clock — we leave the second parameter null.
+ await repo.Received(1).GetKpiSnapshotAsync(
+ TimeSpan.FromHours(1),
+ Arg.Is(v => v == null),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
+ {
+ var repo = Substitute.For();
+ repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
+
+ // plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog.
+ var sites = new Dictionary
+ {
+ ["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true },
+ ["plant-b"] = StateWithBacklog("plant-b", pending: null),
+ ["plant-c"] = StateWithBacklog("plant-c", pending: 4),
+ };
+ var agg = Substitute.For();
+ agg.GetAllSiteStates().Returns(sites);
+
+ var sut = new AuditLogQueryService(repo, agg);
+
+ var snapshot = await sut.GetKpiSnapshotAsync();
+
+ // Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero.
+ Assert.Equal(4, snapshot.BacklogTotal);
+ }
+
+ private static SiteHealthState StateWithBacklog(string siteId, int? pending)
+ {
+ SiteAuditBacklogSnapshot? backlog = pending.HasValue
+ ? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0)
+ : null;
+ var report = new SiteHealthReport(
+ SiteId: siteId,
+ SequenceNumber: 1,
+ ReportTimestamp: DateTimeOffset.UtcNow,
+ DataConnectionStatuses: new Dictionary(),
+ TagResolutionCounts: new Dictionary(),
+ ScriptErrorCount: 0,
+ AlarmEvaluationErrorCount: 0,
+ StoreAndForwardBufferDepths: new Dictionary(),
+ DeadLetterCount: 0,
+ DeployedInstanceCount: 0,
+ EnabledInstanceCount: 0,
+ DisabledInstanceCount: 0,
+ SiteAuditBacklog: backlog);
+ return new SiteHealthState
+ {
+ SiteId = siteId,
+ LatestReport = report,
+ LastReportReceivedAt = DateTimeOffset.UtcNow,
+ LastHeartbeatAt = DateTimeOffset.UtcNow,
+ LastSequenceNumber = 1,
+ IsOnline = true,
+ };
+ }
}
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
index df1daeb..775fb2e 100644
--- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
@@ -510,6 +510,82 @@ public class AuditLogRepositoryTests : IClassFixture
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 ScalarAsync(ScadaLinkDbContext context, string sql)
{
var conn = context.Database.GetDbConnection();