bbfc1b3278
Adds 30d (720h) and 90d (2160h) toggle buttons alongside the existing 24h/7d controls on all four KPI trend surfaces. No fetch-logic changes: the setters already recompute fromUtc and re-fetch, and the query service (T5) transparently routes windows > RollupThresholdHours to hourly rollups — so these windows are what actually exercise the rollup path. bUnit tests extend the Audit and SiteCalls trend harnesses to cover the new buttons. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
402 lines
16 KiB
Plaintext
402 lines
16 KiB
Plaintext
@page "/notifications/kpis"
|
|
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi
|
|
@using ZB.MOM.WW.ScadaBridge.Communication
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
|
@inject CommunicationService CommunicationService
|
|
@inject ISiteRepository SiteRepository
|
|
@inject IKpiHistoryQueryService KpiHistory
|
|
@inject ILogger<NotificationKpis> Logger
|
|
|
|
<div class="container-fluid mt-3">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Notification KPIs</h4>
|
|
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
|
|
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
@* ── Global KPI tiles ── *@
|
|
@if (_kpiError != null)
|
|
{
|
|
<div class="alert alert-warning py-2">KPIs unavailable: @_kpiError</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-lg col-md-4 col-6">
|
|
<div class="card h-100">
|
|
<div class="card-body text-center py-3">
|
|
<h3 class="mb-0">@_kpi.QueueDepth</h3>
|
|
<small class="text-muted">Queue Depth</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg col-md-4 col-6">
|
|
<div class="card h-100 @(_kpi.StuckCount > 0 ? "border-warning" : "")">
|
|
<div class="card-body text-center py-3">
|
|
<h3 class="mb-0 @(_kpi.StuckCount > 0 ? "text-warning" : "")">@_kpi.StuckCount</h3>
|
|
<small class="text-muted">Stuck</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg col-md-4 col-6">
|
|
<div class="card h-100 @(_kpi.ParkedCount > 0 ? "border-danger" : "")">
|
|
<div class="card-body text-center py-3">
|
|
<h3 class="mb-0 @(_kpi.ParkedCount > 0 ? "text-danger" : "")">@_kpi.ParkedCount</h3>
|
|
<small class="text-muted">Parked</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg col-md-4 col-6">
|
|
<div class="card h-100">
|
|
<div class="card-body text-center py-3">
|
|
<h3 class="mb-0 text-success">@_kpi.DeliveredLastInterval</h3>
|
|
<small class="text-muted">Delivered (last interval)</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg col-md-4 col-6">
|
|
<div class="card h-100">
|
|
<div class="card-body text-center py-3">
|
|
<h3 class="mb-0">@FormatAge(_kpi.OldestPendingAge)</h3>
|
|
<small class="text-muted">Oldest Pending Age</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@* ── Per-node breakdown (additive) ── *@
|
|
<h5 class="mb-2">Per-node breakdown</h5>
|
|
@if (_perNodeError != null)
|
|
{
|
|
<div class="alert alert-warning py-2">Per-node KPIs unavailable: @_perNodeError</div>
|
|
}
|
|
else if (_perNode.Count == 0)
|
|
{
|
|
<div class="card mb-3">
|
|
<div class="card-body text-center text-muted py-3">
|
|
<div class="small">No per-node activity (rows may have a null SourceNode).</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive mb-3">
|
|
<table class="table table-sm table-hover align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Node</th>
|
|
<th class="text-end">Queue Depth</th>
|
|
<th class="text-end">Stuck</th>
|
|
<th class="text-end">Parked</th>
|
|
<th class="text-end">Delivered (last interval)</th>
|
|
<th class="text-end">Oldest Pending Age</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var n in _perNode)
|
|
{
|
|
<tr @key="n.SourceNode" class="@(n.StuckCount > 0 ? "table-warning" : "")">
|
|
<td><code>@n.SourceNode</code></td>
|
|
<td class="text-end font-monospace">@n.QueueDepth</td>
|
|
<td class="text-end font-monospace @(n.StuckCount > 0 ? "text-warning" : "")">@n.StuckCount</td>
|
|
<td class="text-end font-monospace @(n.ParkedCount > 0 ? "text-danger" : "")">@n.ParkedCount</td>
|
|
<td class="text-end font-monospace text-success">@n.DeliveredLastInterval</td>
|
|
<td class="text-end font-monospace">@FormatAge(n.OldestPendingAge)</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
|
|
@* ── Per-site breakdown ── *@
|
|
<h5 class="mb-2">Per-site breakdown</h5>
|
|
@if (_perSiteError != null)
|
|
{
|
|
<div class="alert alert-warning py-2">Per-site KPIs unavailable: @_perSiteError</div>
|
|
}
|
|
else if (_perSite.Count == 0)
|
|
{
|
|
<div class="card">
|
|
<div class="card-body text-center text-muted py-4">
|
|
<div class="small">No per-site activity.</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Site</th>
|
|
<th class="text-end">Queue Depth</th>
|
|
<th class="text-end">Stuck</th>
|
|
<th class="text-end">Parked</th>
|
|
<th class="text-end">Delivered (last interval)</th>
|
|
<th class="text-end">Oldest Pending Age</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var s in _perSite)
|
|
{
|
|
<tr @key="s.SourceSiteId" class="@(s.StuckCount > 0 ? "table-warning" : "")">
|
|
<td>@SiteName(s.SourceSiteId)</td>
|
|
<td class="text-end font-monospace">@s.QueueDepth</td>
|
|
<td class="text-end font-monospace @(s.StuckCount > 0 ? "text-warning" : "")">@s.StuckCount</td>
|
|
<td class="text-end font-monospace @(s.ParkedCount > 0 ? "text-danger" : "")">@s.ParkedCount</td>
|
|
<td class="text-end font-monospace text-success">@s.DeliveredLastInterval</td>
|
|
<td class="text-end font-monospace">@FormatAge(s.OldestPendingAge)</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
|
|
@* ── Trends (first KPI-history consumer) ── *@
|
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-2" data-test="notification-trends">
|
|
<h5 class="mb-0">Trends</h5>
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
|
|
<button type="button"
|
|
class="btn @(_windowHours == 24 ? "btn-primary" : "btn-outline-secondary")"
|
|
@onclick="() => SetWindowAsync(24)" disabled="@_trendsLoading">24h</button>
|
|
<button type="button"
|
|
class="btn @(_windowHours == 168 ? "btn-primary" : "btn-outline-secondary")"
|
|
@onclick="() => SetWindowAsync(168)" disabled="@_trendsLoading">7d</button>
|
|
<button type="button"
|
|
class="btn @(_windowHours == 720 ? "btn-primary" : "btn-outline-secondary")"
|
|
@onclick="() => SetWindowAsync(720)" disabled="@_trendsLoading">30d</button>
|
|
<button type="button"
|
|
class="btn @(_windowHours == 2160 ? "btn-primary" : "btn-outline-secondary")"
|
|
@onclick="() => SetWindowAsync(2160)" disabled="@_trendsLoading">90d</button>
|
|
</div>
|
|
</div>
|
|
<div class="row g-3">
|
|
<div class="col-lg-4 col-md-6">
|
|
<KpiTrendChart Title="Queue Depth"
|
|
Points="@_queueDepthSeries"
|
|
IsAvailable="@_queueDepthAvailable"
|
|
ErrorMessage="@_queueDepthError" />
|
|
</div>
|
|
<div class="col-lg-4 col-md-6">
|
|
<KpiTrendChart Title="Parked"
|
|
Points="@_parkedSeries"
|
|
IsAvailable="@_parkedAvailable"
|
|
ErrorMessage="@_parkedError" />
|
|
</div>
|
|
<div class="col-lg-4 col-md-6">
|
|
<KpiTrendChart Title="Delivered / interval"
|
|
Points="@_deliveredSeries"
|
|
IsAvailable="@_deliveredAvailable"
|
|
ErrorMessage="@_deliveredError" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
private List<Site> _sites = new();
|
|
|
|
private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null);
|
|
private string? _kpiError;
|
|
|
|
private IReadOnlyList<SiteNotificationKpiSnapshot> _perSite = Array.Empty<SiteNotificationKpiSnapshot>();
|
|
private string? _perSiteError;
|
|
|
|
// ── Per-node (M5.2 per-node stuck-count KPIs) ──
|
|
private IReadOnlyList<NodeNotificationKpiSnapshot> _perNode = Array.Empty<NodeNotificationKpiSnapshot>();
|
|
private string? _perNodeError;
|
|
|
|
private bool _loading;
|
|
|
|
// ── Trends (first KPI-history consumer) ──
|
|
// Window in hours: 24h (default) or 168h (7d). Toggling re-queries.
|
|
// Per-metric isolation (mirrors the 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 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()
|
|
{
|
|
try
|
|
{
|
|
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Non-fatal — the per-site table falls back to raw site identifiers.
|
|
Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown.");
|
|
}
|
|
|
|
await RefreshAll();
|
|
}
|
|
|
|
private async Task RefreshAll()
|
|
{
|
|
_loading = true;
|
|
// Race-free despite all tasks mutating component fields: Blazor Server runs
|
|
// every continuation on the circuit's single-threaded synchronization context.
|
|
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis(), LoadTrends());
|
|
_loading = false;
|
|
}
|
|
|
|
private async Task SetWindowAsync(int windowHours)
|
|
{
|
|
if (_windowHours == windowHours)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_windowHours = windowHours;
|
|
await LoadTrends();
|
|
}
|
|
|
|
private async Task LoadTrends()
|
|
{
|
|
_trendsLoading = true;
|
|
try
|
|
{
|
|
var toUtc = DateTime.UtcNow;
|
|
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
|
|
|
|
// 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(KpiMetrics.NotificationOutbox.QueueDepth, fromUtc, toUtc);
|
|
(_parkedSeries, _parkedAvailable, _parkedError) =
|
|
await LoadSeries(KpiMetrics.NotificationOutbox.ParkedCount, fromUtc, toUtc);
|
|
(_deliveredSeries, _deliveredAvailable, _deliveredError) =
|
|
await LoadSeries(KpiMetrics.NotificationOutbox.DeliveredLastInterval, fromUtc, toUtc);
|
|
}
|
|
finally
|
|
{
|
|
_trendsLoading = false;
|
|
}
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
try
|
|
{
|
|
var response = await CommunicationService.GetNotificationKpisAsync(
|
|
new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
|
|
if (response.Success)
|
|
{
|
|
_kpi = response;
|
|
_kpiError = null;
|
|
}
|
|
else
|
|
{
|
|
_kpiError = response.ErrorMessage ?? "KPI query failed.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_kpiError = $"KPI query failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private async Task LoadPerSiteKpis()
|
|
{
|
|
try
|
|
{
|
|
var response = await CommunicationService.GetPerSiteNotificationKpisAsync(
|
|
new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N")));
|
|
if (response.Success)
|
|
{
|
|
_perSite = response.Sites;
|
|
_perSiteError = null;
|
|
}
|
|
else
|
|
{
|
|
_perSiteError = response.ErrorMessage ?? "Per-site KPI query failed.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_perSiteError = $"Per-site KPI query failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private async Task LoadPerNodeKpis()
|
|
{
|
|
try
|
|
{
|
|
var response = await CommunicationService.GetPerNodeNotificationKpisAsync(
|
|
new PerNodeNotificationKpiRequest(Guid.NewGuid().ToString("N")));
|
|
if (response.Success)
|
|
{
|
|
_perNode = response.Nodes;
|
|
_perNodeError = null;
|
|
}
|
|
else
|
|
{
|
|
_perNodeError = response.ErrorMessage ?? "Per-node KPI query failed.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_perNodeError = $"Per-node KPI query failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private string SiteName(string siteId) =>
|
|
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
|
|
|
|
private static string FormatAge(TimeSpan? age)
|
|
{
|
|
if (age == null) return "—";
|
|
var t = age.Value;
|
|
if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s";
|
|
if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m";
|
|
if (t.TotalHours < 24) return $"{(int)t.TotalHours}h";
|
|
return $"{(int)t.TotalDays}d";
|
|
}
|
|
}
|