refactor(kpi): K13/K15 trend review fixups — per-metric isolation, disable-during-load + logging, loading-flag finally, test coverage

This commit is contained in:
Joseph Doherty
2026-06-17 20:44:34 -04:00
parent 7d7c6cbb05
commit eb4bce3e49
7 changed files with 151 additions and 55 deletions
@@ -31,12 +31,12 @@
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
<button type="button"
class="btn @(_windowHours == 24 ? "btn-secondary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(24)">
@onclick="() => SetWindowAsync(24)" disabled="@_trendsLoading">
24h
</button>
<button type="button"
class="btn @(_windowHours == 168 ? "btn-secondary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(168)">
@onclick="() => SetWindowAsync(168)" disabled="@_trendsLoading">
7d
</button>
</div>
@@ -2,6 +2,7 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -56,6 +57,10 @@ public partial class AuditLogPage : IDisposable
/// </summary>
[Inject] private IKpiHistoryQueryService KpiHistory { get; set; } = null!;
/// <summary>Logger for the best-effort Trends panel — a degraded series fetch
/// is logged at warning level so the silent fallback is still observable.</summary>
[Inject] private ILogger<AuditLogPage> Logger { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter;
private AuditEventView? _selectedEvent;
private bool _drawerOpen;
@@ -271,6 +276,13 @@ public partial class AuditLogPage : IDisposable
/// <summary>Whether the Trends panel is expanded (open by default).</summary>
private bool _trendsOpen = true;
/// <summary>
/// True while a window's series are being (re)fetched — disables the 24h/7d
/// window toggle buttons so a mid-flight click cannot stack overlapping loads
/// (mirrors the K13/K14 trend pages).
/// </summary>
private bool _trendsLoading;
/// <summary>Active window in hours — 24 (default) or 168 (7 days).</summary>
private int _windowHours = 24;
@@ -297,25 +309,35 @@ public partial class AuditLogPage : IDisposable
/// </summary>
private async Task LoadTrendsAsync()
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
foreach (var (metric, _, _) in TrendMetrics)
_trendsLoading = true;
try
{
try
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
foreach (var (metric, _, _) in TrendMetrics)
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.AuditLog, metric, KpiScopes.Global, scopeKey: null,
fromUtc, toUtc);
_trendSeries[metric] = new TrendSeries(points, IsAvailable: true, ErrorMessage: null);
}
catch (Exception)
{
// Best-effort: degrade this chart only, keep the rest of the page alive.
_trendSeries[metric] = new TrendSeries(
Points: null, IsAvailable: false, ErrorMessage: "Trend data unavailable.");
try
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.AuditLog, metric, KpiScopes.Global, scopeKey: null,
fromUtc, toUtc);
_trendSeries[metric] = new TrendSeries(points, IsAvailable: true, ErrorMessage: null);
}
catch (Exception ex)
{
// Best-effort: degrade this chart only, keep the rest of the page
// alive — but log the failure so the silent fallback is observable.
Logger.LogWarning(ex, "Failed to load Audit Log KPI trend series for metric {Metric}.", metric);
_trendSeries[metric] = new TrendSeries(
Points: null, IsAvailable: false, ErrorMessage: "Trend data unavailable.");
}
}
}
finally
{
_trendsLoading = false;
}
}
/// <summary>Returns the rendered state for a metric, defaulting to available-empty.</summary>
@@ -92,6 +92,7 @@
<select class="form-select form-select-sm" style="width:auto"
data-test="site-health-trends-site"
value="@_trendSiteId"
@* Blazor wraps this async handler in an EventCallback, so the returned Task IS awaited — keep the Async signature; do not change it to a void/fire-and-forget handler. *@
@onchange="OnTrendSiteChangedAsync">
@foreach (var key in _trendSiteKeys)
{
@@ -178,20 +178,20 @@
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Queue Depth"
Points="@_queueDepthSeries"
IsAvailable="@_trendsAvailable"
ErrorMessage="@_trendsError" />
IsAvailable="@_queueDepthAvailable"
ErrorMessage="@_queueDepthError" />
</div>
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Parked"
Points="@_parkedSeries"
IsAvailable="@_trendsAvailable"
ErrorMessage="@_trendsError" />
IsAvailable="@_parkedAvailable"
ErrorMessage="@_parkedError" />
</div>
<div class="col-lg-4 col-md-6">
<KpiTrendChart Title="Delivered / interval"
Points="@_deliveredSeries"
IsAvailable="@_trendsAvailable"
ErrorMessage="@_trendsError" />
IsAvailable="@_deliveredAvailable"
ErrorMessage="@_deliveredError" />
</div>
</div>
</div>
@@ -213,13 +213,24 @@
// ── Trends (T11: first KPI-history consumer) ──
// Window in hours: 24h (default) or 168h (7d). Toggling re-queries.
// Per-metric isolation (mirrors the K14 SiteCallsReport pattern): each metric
// carries its own series + availability + error, each loaded via its own
// try/catch so one metric's failure only blanks that one chart and the others
// still render their already-fetched data.
private int _windowHours = 24;
private bool _trendsLoading;
private bool _trendsAvailable = true;
private string? _trendsError;
private IReadOnlyList<KpiSeriesPoint>? _queueDepthSeries;
private bool _queueDepthAvailable = true;
private string? _queueDepthError;
private IReadOnlyList<KpiSeriesPoint>? _parkedSeries;
private bool _parkedAvailable = true;
private string? _parkedError;
private IReadOnlyList<KpiSeriesPoint>? _deliveredSeries;
private bool _deliveredAvailable = true;
private string? _deliveredError;
protected override async Task OnInitializedAsync()
{
@@ -264,21 +275,15 @@
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
// Best-effort: one query failure must NOT break the page — on any
// exception the charts fall back to the unavailable placeholder while
// the KPI tiles above stay rendered.
_queueDepthSeries = await GetSeries("queueDepth", fromUtc, toUtc);
_parkedSeries = await GetSeries("parkedCount", fromUtc, toUtc);
_deliveredSeries = await GetSeries("deliveredLastInterval", fromUtc, toUtc);
_trendsAvailable = true;
_trendsError = null;
}
catch (Exception ex)
{
_trendsAvailable = false;
_trendsError = "Trend data unavailable.";
Logger.LogWarning(ex, "Failed to load notification-outbox KPI trend series.");
// Per-metric isolation: each series is loaded via its own try/catch so
// one metric's failure only blanks that one chart while the others still
// render their fetched data — and a throw never breaks the KPI tiles above.
(_queueDepthSeries, _queueDepthAvailable, _queueDepthError) =
await LoadSeries("queueDepth", fromUtc, toUtc);
(_parkedSeries, _parkedAvailable, _parkedError) =
await LoadSeries("parkedCount", fromUtc, toUtc);
(_deliveredSeries, _deliveredAvailable, _deliveredError) =
await LoadSeries("deliveredLastInterval", fromUtc, toUtc);
}
finally
{
@@ -286,9 +291,28 @@
}
}
private Task<IReadOnlyList<KpiSeriesPoint>> GetSeries(string metric, DateTime fromUtc, DateTime toUtc) =>
KpiHistory.GetSeriesAsync(
KpiSources.NotificationOutbox, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
/// <summary>
/// Fetch one NotificationOutbox / Global series, swallowing any failure into the
/// chart's unavailable state. Returns the points (null on failure), the
/// availability flag, and a short error message for the chart placeholder.
/// </summary>
private async Task<(IReadOnlyList<KpiSeriesPoint>? Points, bool Available, string? Error)>
LoadSeries(string metric, DateTime fromUtc, DateTime toUtc)
{
try
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.NotificationOutbox, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
return (points, true, null);
}
catch (Exception ex)
{
// A KPI-history hiccup must never break the page — the chart degrades to
// its unavailable placeholder while the KPI tiles above stay rendered.
Logger.LogWarning(ex, "Failed to load notification-outbox KPI trend series for metric {Metric}.", metric);
return (null, false, "Trend data unavailable.");
}
}
private async Task LoadGlobalKpis()
{
@@ -585,18 +585,26 @@ public partial class SiteCallsReport
private async Task LoadTrendsAsync()
{
_trendsLoading = true;
try
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
(_bufferedSeries, _bufferedAvailable, _bufferedError) =
await LoadSeriesAsync("buffered", fromUtc, toUtc);
(_parkedSeries, _parkedAvailable, _parkedError) =
await LoadSeriesAsync("parked", fromUtc, toUtc);
(_failedSeries, _failedAvailable, _failedError) =
await LoadSeriesAsync("failedLastInterval", fromUtc, toUtc);
_trendsLoading = false;
(_bufferedSeries, _bufferedAvailable, _bufferedError) =
await LoadSeriesAsync("buffered", fromUtc, toUtc);
(_parkedSeries, _parkedAvailable, _parkedError) =
await LoadSeriesAsync("parked", fromUtc, toUtc);
(_failedSeries, _failedAvailable, _failedError) =
await LoadSeriesAsync("failedLastInterval", fromUtc, toUtc);
}
finally
{
// try/finally so an unexpected throw before this line can't lock the
// spinner/disabled state on forever. The per-metric LoadSeriesAsync
// try/catches already swallow GetSeriesAsync failures, so this guards
// only the truly unexpected.
_trendsLoading = false;
}
}
/// <summary>