feat(ui): Audit KPI tiles on Health dashboard (#23 M7)

Adds three KPI tiles to the central Health dashboard for the Audit channel:
volume (rows in the last hour), error rate (Failed/Parked/Discarded over
total), and backlog (sum of SiteAuditBacklog.PendingCount across all sites).

Repo + service:
- IAuditLogRepository.GetKpiSnapshotAsync(window, nowUtc) — single aggregate
  SELECT over the trailing window returning total + error counts; nowUtc is
  optional for production callers and pinned by integration tests against the
  shared MSSQL fixture so the global counts are deterministic.
- AuditLogQueryService.GetKpiSnapshotAsync() — composes the repo aggregate
  with a sum of SiteAuditBacklog.PendingCount read from ICentralHealthAggregator.
- AuditLogKpiSnapshot record in Commons/Types/.

UI:
- New AuditKpiTiles Blazor component (Components/Health/) — three Bootstrap
  card-tiles, click navigates to /audit/log with the matching pre-filter.
- Health.razor wires the tiles in alongside the existing Notification Outbox
  KPIs; LoadAuditKpis() runs on every 10s refresh tick and degrades to em
  dashes + inline error if the query fails.
- AuditLogPage extended to parse ?status= so the error-rate tile drill-in
  (?status=Failed) auto-loads the grid.

Tests:
- AuditLogRepositoryTests: GetKpiSnapshotAsync mixed-status + empty-window
  cases against the MSSQL migration fixture.
- AuditLogQueryServiceTests: forwarding + backlog composition; sites with
  null SiteAuditBacklog contribute zero.
- AuditKpiTilesTests: 9 bUnit tests covering tile render, error-rate maths
  with safe zero-events handling, em-dash unavailable path, click-through
  navigation, and warning/danger border thresholds.
- HealthPageTests: new Renders_AuditKpiTiles_WithValues plus IAuditLogQueryService
  stub registration in the constructor so existing outbox tests still pass.
- AuditLogPageScaffoldTests: ?status=Failed auto-load + unknown status drop.
This commit is contained in:
Joseph Doherty
2026-05-20 20:43:57 -04:00
parent 38fc9b4102
commit 943c2ced39
18 changed files with 969 additions and 7 deletions

View File

@@ -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 &rarr;</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>
}

View 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");
}
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 };
}
}

View File

@@ -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);
}

View File

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

View File

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

View File

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