From 943c2ced3976139f496e777818e152fe433e9ad0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:43:57 -0400 Subject: [PATCH] feat(ui): Audit KPI tiles on Health dashboard (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Components/Health/AuditKpiTiles.razor | 59 +++++++ .../Components/Health/AuditKpiTiles.razor.cs | 157 +++++++++++++++++ .../Pages/Audit/AuditLogPage.razor.cs | 20 ++- .../Components/Pages/Monitoring/Health.razor | 36 ++++ .../Services/AuditLogQueryService.cs | 41 ++++- .../Services/IAuditLogQueryService.cs | 23 +++ .../Repositories/IAuditLogRepository.cs | 47 ++++++ .../Types/AuditLogKpiSnapshot.cs | 38 +++++ .../Repositories/AuditLogRepository.cs | 114 +++++++++++++ .../Central/AuditLogIngestActorTests.cs | 4 + .../Central/AuditLogPurgeActorTests.cs | 4 + .../Central/CentralAuditWriteFailuresTests.cs | 3 + .../SiteAuditReconciliationActorTests.cs | 4 + .../Components/Health/AuditKpiTilesTests.cs | 158 ++++++++++++++++++ .../Pages/AuditLogPageScaffoldTests.cs | 38 +++++ .../Pages/HealthPageTests.cs | 41 +++++ .../Services/AuditLogQueryServiceTests.cs | 113 ++++++++++++- .../Repositories/AuditLogRepositoryTests.cs | 76 +++++++++ 18 files changed, 969 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor create mode 100644 src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs create mode 100644 src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor new file mode 100644 index 0000000..0113fc0 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor @@ -0,0 +1,59 @@ +@* + Audit Log (#23) M7 Bundle E (T13) — three Health-dashboard KPI tiles for the + Audit channel: Volume / Error rate / Backlog. Renders Bootstrap card tiles in + a single row, each acting as a navigation link to a pre-filtered Audit Log + view. The component is purely presentational — the parent page owns the + refresh loop and passes the latest snapshot via the Snapshot parameter. +*@ + +@namespace ScadaLink.CentralUI.Components.Health +@inject NavigationManager Navigation + +
+
Audit
+ View details → +
+
+ @* ── Volume tile ───────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Error rate tile ───────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Backlog tile ──────────────────────────────────────────────────────── *@ +
+ +
+
+@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage)) +{ +
Audit KPIs unavailable: @ErrorMessage
+} diff --git a/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs new file mode 100644 index 0000000..5c6ede1 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Types; + +namespace ScadaLink.CentralUI.Components.Health; + +/// +/// Audit Log (#23) M7 Bundle E (T13) code-behind for . +/// Renders three KPI tiles — volume, error rate, backlog — from a +/// the parent page supplies. Tiles act as +/// drill-in links: clicking navigates to /audit/log with the relevant +/// query-string filter pre-applied (Bundle D already parses these params). +/// +/// +/// +/// Why purely presentational. The Health dashboard already owns a 10s +/// auto-refresh loop and an "as-of" timestamp display; pushing those concerns +/// into the tile component would either duplicate them (one timer per tile) or +/// awkwardly couple back to the page. The parent passes a fresh +/// every refresh and the tile component +/// re-renders. +/// +/// +/// Error rate division. When TotalEventsLastHour == 0 we render +/// "0%" rather than "—" — the snapshot itself is available, the system just had +/// no audit traffic to evaluate. This avoids a divide-by-zero AND keeps the +/// "0% errors" reading semantically true. The em dash is reserved for +/// = false, which represents a failed snapshot +/// query (different signal from "quiet hour"). +/// +/// +public partial class AuditKpiTiles +{ + /// + /// Latest KPI snapshot. null means the parent has not loaded it yet + /// or the load failed — the tiles render em dashes in that case. + /// + [Parameter] public AuditLogKpiSnapshot? Snapshot { get; set; } + + /// + /// True when is a successful query result. False + /// when the parent's refresh threw and the displayed values should be + /// rendered as em dashes with an error explanation underneath. + /// + [Parameter] public bool IsAvailable { get; set; } + + /// + /// Optional error message to render underneath the tiles when + /// is false. Mirrors how the Notification Outbox + /// section on the Health dashboard surfaces transient KPI failures. + /// + [Parameter] public string? ErrorMessage { get; set; } + + // ── Volume tile ───────────────────────────────────────────────────────── + + private string VolumeDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.TotalEventsLastHour.ToString("N0") + : "—"; + + private void NavigateToVolume() + { + // Volume is "all audit rows in the last hour" — no status filter; the + // page's existing instance-search seam is enough for drill-in. We rely + // on the page's default render which omits a time-range constraint and + // shows the newest rows first. + Navigation.NavigateTo("/audit/log"); + } + + // ── Error rate tile ───────────────────────────────────────────────────── + + /// + /// Percentage of error rows (Failed/Parked/Discarded) over the trailing + /// hour. Returns 0 when the snapshot is unavailable OR when total events + /// is zero (rather than throwing). The display layer renders "—" for the + /// unavailable case and "0%" for the zero-events case. + /// + internal double ErrorRatePercent + { + get + { + if (!IsAvailable || Snapshot is null || Snapshot.TotalEventsLastHour <= 0) + { + return 0; + } + return 100.0 * Snapshot.ErrorEventsLastHour / Snapshot.TotalEventsLastHour; + } + } + + private string ErrorRateDisplay + { + get + { + if (!IsAvailable || Snapshot is null) + { + return "—"; + } + // Format to one decimal so a 1-error-in-2000 rate doesn't round to 0%. + return $"{ErrorRatePercent:0.0}%"; + } + } + + // Border + text colour bracket the tile visually: any nonzero error rate + // gets a warning border; anything above 10% bumps it to danger. The + // thresholds match the Notification Outbox tile pattern (border-warning + // when Stuck > 0, border-danger when Parked > 0). + private string ErrorRateBorderClass => + !IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0 + ? string.Empty + : (ErrorRatePercent >= 10 ? "border-danger" : "border-warning"); + + private string ErrorRateTextClass => + !IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0 + ? string.Empty + : (ErrorRatePercent >= 10 ? "text-danger" : "text-warning"); + + private void NavigateToErrors() + { + // Drill in pre-filtered to Failed — the most common error class. + // (The Audit Log page also accepts ?status=Parked / =Discarded for + // operators who want to see those specifically; the tile picks Failed + // as the primary surface since it's the only synchronous-failure + // status. Parked + Discarded both still appear in the unfiltered grid.) + Navigation.NavigateTo("/audit/log?status=Failed"); + } + + // ── Backlog tile ──────────────────────────────────────────────────────── + + private string BacklogDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.BacklogTotal.ToString("N0") + : "—"; + + // Backlog above zero is itself a signal — sites should normally drain to + // empty. We render warning when there's a backlog at all; a hard danger + // threshold could be added later if ops want it but the on-call playbook + // for "backlog > 0" is the same as "backlog > 1000": check why the site + // isn't draining. + private string BacklogBorderClass => + IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0 + ? "border-warning" + : string.Empty; + + private string BacklogTextClass => + IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0 + ? "text-warning" + : string.Empty; + + private void NavigateToBacklog() + { + // The audit-log page itself doesn't carry a per-site backlog grid — + // the Health dashboard already shows that per-site card. The natural + // drill-in for "the system has a backlog" is the unfiltered Audit Log + // page sorted by newest, so an operator can see the most recent rows + // and judge whether the queue is moving. + Navigation.NavigateTo("/audit/log"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 240e837..bc26789 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -19,8 +19,10 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can /// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, /// ?actor=, ?site=, ?channel=, and the UI-only -/// ?instance= are read on initialization. When any param is present we -/// allocate a fresh and assign it to +/// ?instance= are read on initialization. Bundle E (M7-T13) extends +/// this with ?status= so the Health-dashboard Audit error-rate tile can +/// drill in to ?status=Failed. When any param is present we allocate a +/// fresh and assign it to /// , which kicks the results grid into auto-load /// without the user clicking Apply. Unknown values (e.g. an invalid enum name) /// are silently dropped — the page still renders, just without that constraint. @@ -94,6 +96,17 @@ public partial class AuditLogPage channel = parsedChannel; } + // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in + // with ?status=Failed (and operators may craft URLs with Parked/Discarded). + // Unknown values are silently dropped — the page still renders without + // the constraint. + AuditStatus? status = null; + if (query.TryGetValue("status", out var statusValues) + && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) + { + status = parsedStatus; + } + // Instance is UI-only — the filter contract has no matching column, so we // pass it as a separate seam to the filter bar. if (query.TryGetValue("instance", out var instanceValues)) @@ -109,13 +122,14 @@ public partial class AuditLogPage // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load // because the filter contract has no instance column — the user still needs // to refine + Apply for those. - if (correlationId is null && target is null && actor is null && site is null && channel is null) + if (correlationId is null && target is null && actor is null && site is null && channel is null && status is null) { return; } _currentFilter = new AuditLogQueryFilter( Channel: channel, + Status: status, SourceSiteId: site, Target: target, Actor: actor, diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index bbff99b..58a87d1 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -1,5 +1,8 @@ @page "/monitoring/health" @attribute [Authorize] +@using ScadaLink.CentralUI.Components.Health +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Types @using ScadaLink.Commons.Types.Enums @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @@ -10,6 +13,7 @@ @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService +@inject IAuditLogQueryService AuditLogQueryService
@@ -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();