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:
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-muted mb-0">Audit</h6>
|
||||
<a class="small" href="/audit/log">View details →</a>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
@* ── Volume tile ───────────────────────────────────────────────────────── *@
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<button type="button"
|
||||
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile"
|
||||
data-test="audit-kpi-volume"
|
||||
@onclick="NavigateToVolume">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0">@VolumeDisplay</h3>
|
||||
<small class="text-muted">Audit volume (last hour)</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ── Error rate tile ───────────────────────────────────────────────────── *@
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<button type="button"
|
||||
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile @ErrorRateBorderClass"
|
||||
data-test="audit-kpi-error-rate"
|
||||
@onclick="NavigateToErrors">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @ErrorRateTextClass">@ErrorRateDisplay</h3>
|
||||
<small class="text-muted">Audit error rate (last hour)</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ── Backlog tile ──────────────────────────────────────────────────────── *@
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<button type="button"
|
||||
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile @BacklogBorderClass"
|
||||
data-test="audit-kpi-backlog"
|
||||
@onclick="NavigateToBacklog">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @BacklogTextClass">@BacklogDisplay</h3>
|
||||
<small class="text-muted">Audit backlog (sites pending)</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="text-muted small mb-3">Audit KPIs unavailable: @ErrorMessage</div>
|
||||
}
|
||||
157
src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs
Normal file
157
src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) code-behind for <see cref="AuditKpiTiles"/>.
|
||||
/// Renders three KPI tiles — volume, error rate, backlog — from a
|
||||
/// <see cref="AuditLogKpiSnapshot"/> the parent page supplies. Tiles act as
|
||||
/// drill-in links: clicking navigates to <c>/audit/log</c> with the relevant
|
||||
/// query-string filter pre-applied (Bundle D already parses these params).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why purely presentational.</b> 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
|
||||
/// <see cref="AuditLogKpiSnapshot"/> every refresh and the tile component
|
||||
/// re-renders.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error rate division.</b> When <c>TotalEventsLastHour == 0</c> 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
|
||||
/// <see cref="IsAvailable"/> = <c>false</c>, which represents a failed snapshot
|
||||
/// query (different signal from "quiet hour").
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class AuditKpiTiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
|
||||
/// or the load failed — the tiles render em dashes in that case.
|
||||
/// </summary>
|
||||
[Parameter] public AuditLogKpiSnapshot? Snapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="Snapshot"/> 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.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional error message to render underneath the tiles when
|
||||
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
|
||||
/// section on the Health dashboard surfaces transient KPI failures.
|
||||
/// </summary>
|
||||
[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 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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: <c>?correlationId=</c>, <c>?target=</c>,
|
||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only
|
||||
/// <c>?instance=</c> are read on initialization. When any param is present we
|
||||
/// allocate a fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||
/// <see cref="_currentFilter"/>, 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<AuditStatus>(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,
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -56,6 +60,12 @@
|
||||
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
||||
}
|
||||
|
||||
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
|
||||
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
||||
<AuditKpiTiles Snapshot="@_auditKpi"
|
||||
IsAvailable="@_auditKpiAvailable"
|
||||
ErrorMessage="@_auditKpiError" />
|
||||
|
||||
@if (_siteStates.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No site health reports received yet.</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AuditLogKpiSnapshot> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/// <summary>Default page size when callers don't specify one.</summary>
|
||||
int DefaultPageSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — returns the point-in-time KPI snapshot
|
||||
/// the Health dashboard's Audit tiles render. Composes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c> from
|
||||
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetKpiSnapshotAsync"/>
|
||||
/// (1-hour trailing window).</item>
|
||||
/// <item><c>BacklogTotal</c> from the sum of every site's
|
||||
/// <c>SiteHealthReport.SiteAuditBacklog.PendingCount</c> via
|
||||
/// <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||
@@ -87,4 +88,50 @@ public interface IAuditLogRepository
|
||||
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — returns aggregate counts over the
|
||||
/// trailing <paramref name="window"/> driving the central Health
|
||||
/// dashboard's Audit KPI tiles.
|
||||
/// </summary>
|
||||
/// <param name="window">
|
||||
/// Trailing time window (e.g. <c>TimeSpan.FromHours(1)</c>). Rows whose
|
||||
/// <c>OccurredAtUtc >= nowUtc - window</c> are counted; the upper
|
||||
/// bound is <paramref name="nowUtc"/>.
|
||||
/// </param>
|
||||
/// <param name="nowUtc">
|
||||
/// Optional explicit "now" timestamp used to anchor the trailing window.
|
||||
/// Defaults to <see cref="DateTime.UtcNow"/> at call time when null —
|
||||
/// production callers should leave this null; tests pin a deterministic
|
||||
/// value so the window is reproducible across runs.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A snapshot with <c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c>
|
||||
/// populated; <c>BacklogTotal</c> is left at zero (this method has no
|
||||
/// visibility into per-site backlogs — the service layer composes it in
|
||||
/// from <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>).
|
||||
/// <c>AsOfUtc</c> is set to the server-side <c>UtcNow</c> at the time of
|
||||
/// the query.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implemented as a single aggregate query
|
||||
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors</c>) rather than
|
||||
/// two round trips so the volume + error rate tiles read a consistent
|
||||
/// snapshot — the denominator and numerator come from the same scan.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Errors are defined as <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Failed"/>,
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Parked"/>, or
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Discarded"/>
|
||||
/// — every non-success terminal lifecycle state. <c>Submitted</c>,
|
||||
/// <c>Forwarded</c>, <c>Attempted</c> are in-flight and are NOT errors;
|
||||
/// <c>Delivered</c> is success; <c>Skipped</c> is an intentional no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window,
|
||||
DateTime? nowUtc = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
38
src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs
Normal file
38
src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ScadaLink.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — point-in-time KPI snapshot for the central
|
||||
/// Health dashboard's "Audit" tile group. Aggregates volume + error counts over
|
||||
/// the trailing window from the central <c>AuditLog</c> table and combines them
|
||||
/// with the global pending backlog summed across every site's
|
||||
/// <see cref="SiteAuditBacklogSnapshot"/>.
|
||||
/// </summary>
|
||||
/// <param name="TotalEventsLastHour">
|
||||
/// Total <c>AuditLog</c> rows whose <c>OccurredAtUtc</c> falls inside the trailing
|
||||
/// 1-hour window. Drives the "Audit volume" tile and the denominator of
|
||||
/// "Audit error rate". A zero value renders as "0" rather than an em dash —
|
||||
/// "zero rows in the last hour" is a real, valid signal in a quiet system.
|
||||
/// </param>
|
||||
/// <param name="ErrorEventsLastHour">
|
||||
/// Total <c>AuditLog</c> rows in the same window whose <see cref="Enums.AuditStatus"/>
|
||||
/// is <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c>. Drives the "Audit error
|
||||
/// rate" tile numerator; clicking the tile drills in to <c>/audit/log</c>
|
||||
/// pre-filtered on one of those statuses.
|
||||
/// </param>
|
||||
/// <param name="BacklogTotal">
|
||||
/// Sum of <c>SiteAuditBacklog.PendingCount</c> across every site's latest
|
||||
/// <see cref="ScadaLink.Commons.Messages.Health.SiteHealthReport"/>. Sites whose
|
||||
/// snapshot is <c>null</c> (no report yet, or reporter not running) contribute
|
||||
/// zero. A persistently non-zero value across multiple refresh ticks indicates
|
||||
/// the site→central drain isn't keeping up.
|
||||
/// </param>
|
||||
/// <param name="AsOfUtc">
|
||||
/// UTC timestamp at which the snapshot was computed. Used by the UI to label
|
||||
/// "as of HH:mm:ss" beneath the tile group and to detect stale data when a
|
||||
/// refresh tick fails.
|
||||
/// </param>
|
||||
public sealed record AuditLogKpiSnapshot(
|
||||
long TotalEventsLastHour,
|
||||
long ErrorEventsLastHour,
|
||||
long BacklogTotal,
|
||||
DateTime AsOfUtc);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,5 +220,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
ThresholdQueries.Add(threshold);
|
||||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
||||
}
|
||||
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> 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)
|
||||
|
||||
@@ -48,6 +48,9 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -93,6 +93,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
|
||||
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
|
||||
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
|
||||
/// <item>Error-rate maths: <c>ErrorEventsLastHour / TotalEventsLastHour</c> with
|
||||
/// safe zero-events handling (no DivideByZero, displays "0.0%").</item>
|
||||
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
|
||||
/// <item>Tile clicks navigate to the correct pre-filtered Audit Log URL.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<AuditKpiTiles>(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<AuditKpiTiles>(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<AuditKpiTiles>(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<AuditKpiTiles>(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<AuditKpiTiles>(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<NavigationManager>();
|
||||
|
||||
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<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
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<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
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<AuditKpiTiles>(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<AuditKpiTiles>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
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<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
||||
{
|
||||
|
||||
@@ -6,9 +6,11 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Notification;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
|
||||
@@ -55,6 +57,16 @@ public class HealthPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
|
||||
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
|
||||
// the existing assertions (Notification Outbox tiles, Online/Offline
|
||||
// counts) keep passing; tests that target the Audit tiles set their
|
||||
// own substitute.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
@@ -92,6 +104,35 @@ public class HealthPageTests : BunitContext
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_AuditKpiTiles_WithValues()
|
||||
{
|
||||
// Override the default empty snapshot — this test wants concrete values
|
||||
// to land in the three Audit tiles.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 250,
|
||||
ErrorEventsLastHour: 5,
|
||||
BacklogTotal: 17,
|
||||
AsOfUtc: DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The three audit tiles render at the documented data-test selectors.
|
||||
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
|
||||
// Volume shows the formatted thousand-separator value.
|
||||
Assert.Contains("250", cut.Markup);
|
||||
// Backlog renders 17.
|
||||
Assert.Contains("17", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
public class AuditLogQueryServiceTests
|
||||
{
|
||||
private static ICentralHealthAggregator EmptyAggregator()
|
||||
{
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
|
||||
return agg;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||
{
|
||||
@@ -28,7 +38,7 @@ public class AuditLogQueryServiceTests
|
||||
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(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<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
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<IAuditLogRepository>();
|
||||
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<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.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<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = StateWithBacklog("plant-a", pending: 5),
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: 11),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
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<DateTime?>(v => v == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.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<string, SiteHealthState>
|
||||
{
|
||||
["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<ICentralHealthAggregator>();
|
||||
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<string, ConnectionHealth>(),
|
||||
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||
ScriptErrorCount: 0,
|
||||
AlarmEvaluationErrorCount: 0,
|
||||
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,6 +510,82 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
|
||||
{
|
||||
var conn = context.Database.GetDbConnection();
|
||||
|
||||
Reference in New Issue
Block a user