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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user