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
|
/// 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>,
|
/// 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>?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
|
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||||
/// allocate a fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
/// 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
|
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
||||||
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
||||||
/// are silently dropped — the page still renders, just without that constraint.
|
/// are silently dropped — the page still renders, just without that constraint.
|
||||||
@@ -94,6 +96,17 @@ public partial class AuditLogPage
|
|||||||
channel = parsedChannel;
|
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
|
// Instance is UI-only — the filter contract has no matching column, so we
|
||||||
// pass it as a separate seam to the filter bar.
|
// pass it as a separate seam to the filter bar.
|
||||||
if (query.TryGetValue("instance", out var instanceValues))
|
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
|
// 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
|
// because the filter contract has no instance column — the user still needs
|
||||||
// to refine + Apply for those.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentFilter = new AuditLogQueryFilter(
|
_currentFilter = new AuditLogQueryFilter(
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
|
Status: status,
|
||||||
SourceSiteId: site,
|
SourceSiteId: site,
|
||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@page "/monitoring/health"
|
@page "/monitoring/health"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@using ScadaLink.CentralUI.Components.Health
|
||||||
|
@using ScadaLink.CentralUI.Services
|
||||||
|
@using ScadaLink.Commons.Types
|
||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@@ -10,6 +13,7 @@
|
|||||||
@inject ICentralHealthAggregator HealthAggregator
|
@inject ICentralHealthAggregator HealthAggregator
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
|
@inject IAuditLogQueryService AuditLogQueryService
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-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>
|
<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)
|
@if (_siteStates.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">No site health reports received yet.</div>
|
<div class="alert alert-info">No site health reports received yet.</div>
|
||||||
@@ -347,6 +357,13 @@
|
|||||||
private bool _outboxKpiAvailable;
|
private bool _outboxKpiAvailable;
|
||||||
private string? _outboxKpiError;
|
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)
|
private static bool SiteHasActiveErrors(SiteHealthState state)
|
||||||
{
|
{
|
||||||
var report = state.LatestReport;
|
var report = state.LatestReport;
|
||||||
@@ -384,6 +401,7 @@
|
|||||||
{
|
{
|
||||||
_siteStates = HealthAggregator.GetAllSiteStates();
|
_siteStates = HealthAggregator.GetAllSiteStates();
|
||||||
await LoadOutboxKpis();
|
await LoadOutboxKpis();
|
||||||
|
await LoadAuditKpis();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadOutboxKpis()
|
private async Task LoadOutboxKpis()
|
||||||
@@ -416,6 +434,24 @@
|
|||||||
private string OutboxTileValue(int value) =>
|
private string OutboxTileValue(int value) =>
|
||||||
_outboxKpiAvailable ? value.ToString() : "—";
|
_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)
|
private string GetSiteName(string siteId)
|
||||||
{
|
{
|
||||||
return _siteNames.GetValueOrDefault(siteId, siteId);
|
return _siteNames.GetValueOrDefault(siteId, siteId);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.HealthMonitoring;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Services;
|
namespace ScadaLink.CentralUI.Services;
|
||||||
|
|
||||||
@@ -11,11 +13,21 @@ namespace ScadaLink.CentralUI.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditLogQueryService : IAuditLogQueryService
|
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));
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
|
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
||||||
}
|
}
|
||||||
|
|
||||||
public int DefaultPageSize => 100;
|
public int DefaultPageSize => 100;
|
||||||
@@ -29,4 +41,29 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
|
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
|
||||||
return _repository.QueryAsync(filter, effective, ct);
|
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.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Services;
|
namespace ScadaLink.CentralUI.Services;
|
||||||
@@ -27,4 +28,26 @@ public interface IAuditLogQueryService
|
|||||||
|
|
||||||
/// <summary>Default page size when callers don't specify one.</summary>
|
/// <summary>Default page size when callers don't specify one.</summary>
|
||||||
int DefaultPageSize { get; }
|
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.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
namespace ScadaLink.Commons.Interfaces.Repositories;
|
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||||
@@ -87,4 +88,50 @@ public interface IAuditLogRepository
|
|||||||
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold,
|
DateTime threshold,
|
||||||
CancellationToken ct = default);
|
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 Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
@@ -421,4 +422,117 @@ VALUES
|
|||||||
|
|
||||||
return results;
|
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(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
_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);
|
ThresholdQueries.Add(threshold);
|
||||||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
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)
|
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
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>
|
/// <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]
|
[Fact]
|
||||||
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
using ScadaLink.Commons.Entities.Sites;
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Communication;
|
using ScadaLink.Communication;
|
||||||
using ScadaLink.HealthMonitoring;
|
using ScadaLink.HealthMonitoring;
|
||||||
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
|
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
|
||||||
@@ -55,6 +57,16 @@ public class HealthPageTests : BunitContext
|
|||||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||||
Services.AddSingleton(siteRepo);
|
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[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim("Username", "tester"),
|
new Claim("Username", "tester"),
|
||||||
@@ -92,6 +104,35 @@ public class HealthPageTests : BunitContext
|
|||||||
Assert.Contains("View details", link.TextContent);
|
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]
|
[Fact]
|
||||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ using NSubstitute;
|
|||||||
using ScadaLink.CentralUI.Services;
|
using ScadaLink.CentralUI.Services;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Messages.Health;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.HealthMonitoring;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Tests.Services;
|
namespace ScadaLink.CentralUI.Tests.Services;
|
||||||
|
|
||||||
@@ -15,6 +18,13 @@ namespace ScadaLink.CentralUI.Tests.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogQueryServiceTests
|
public class AuditLogQueryServiceTests
|
||||||
{
|
{
|
||||||
|
private static ICentralHealthAggregator EmptyAggregator()
|
||||||
|
{
|
||||||
|
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||||
|
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
|
||||||
|
return agg;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||||
{
|
{
|
||||||
@@ -28,7 +38,7 @@ public class AuditLogQueryServiceTests
|
|||||||
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
|
||||||
|
|
||||||
var sut = new AuditLogQueryService(repo);
|
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||||
|
|
||||||
var result = await sut.QueryAsync(filter, paging);
|
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>())
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
.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);
|
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
|
||||||
|
|
||||||
@@ -54,4 +64,103 @@ public class AuditLogQueryServiceTests
|
|||||||
Assert.Null(observed.AfterOccurredAtUtc);
|
Assert.Null(observed.AfterOccurredAtUtc);
|
||||||
Assert.Null(observed.AfterEventId);
|
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);
|
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)
|
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
|
||||||
{
|
{
|
||||||
var conn = context.Database.GetDbConnection();
|
var conn = context.Database.GetDbConnection();
|
||||||
|
|||||||
Reference in New Issue
Block a user