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

Notification KPIs

@* ── Global KPI tiles ── *@ @if (_kpiError != null) {
KPIs unavailable: @_kpiError
} else {

@_kpi.QueueDepth

Queue Depth

@_kpi.StuckCount

Stuck

@_kpi.ParkedCount

Parked

@_kpi.DeliveredLastInterval

Delivered (last interval)

@FormatAge(_kpi.OldestPendingAge)

Oldest Pending Age
} @* ── Per-node breakdown (additive) ── *@
Per-node breakdown
@if (_perNodeError != null) {
Per-node KPIs unavailable: @_perNodeError
} else if (_perNode.Count == 0) {
No per-node activity (rows may have a null SourceNode).
} else {
@foreach (var n in _perNode) { }
Node Queue Depth Stuck Parked Delivered (last interval) Oldest Pending Age
@n.SourceNode @n.QueueDepth @n.StuckCount @n.ParkedCount @n.DeliveredLastInterval @FormatAge(n.OldestPendingAge)
} @* ── Per-site breakdown ── *@
Per-site breakdown
@if (_perSiteError != null) {
Per-site KPIs unavailable: @_perSiteError
} else if (_perSite.Count == 0) {
No per-site activity.
} else {
@foreach (var s in _perSite) { }
Site Queue Depth Stuck Parked Delivered (last interval) Oldest Pending Age
@SiteName(s.SourceSiteId) @s.QueueDepth @s.StuckCount @s.ParkedCount @s.DeliveredLastInterval @FormatAge(s.OldestPendingAge)
} @* ── Trends (first KPI-history consumer) ── *@
Trends
@code { private List _sites = new(); private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null); private string? _kpiError; private IReadOnlyList _perSite = Array.Empty(); private string? _perSiteError; // ── Per-node (M5.2 per-node stuck-count KPIs) ── private IReadOnlyList _perNode = Array.Empty(); 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? _queueDepthSeries; private bool _queueDepthAvailable = true; private string? _queueDepthError; private IReadOnlyList? _parkedSeries; private bool _parkedAvailable = true; private string? _parkedError; private IReadOnlyList? _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; } } /// /// 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. /// private async Task<(IReadOnlyList? 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"; } }