From 209f368cb5c221463b5e7474717656a982858605 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 21:34:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(audit):=20M5.2=20per-node=20stuck-count=20?= =?UTF-8?q?KPIs=20(T6)=20=E2=80=94=20repo=20per-node=20aggregation,=20acto?= =?UTF-8?q?r=20message=20pair,=20CentralUI=20tiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Health/SiteCallKpiTiles.razor | 28 ++++ .../Health/SiteCallKpiTiles.razor.cs | 19 +++ .../Components/Pages/Monitoring/Health.razor | 37 ++++- .../Notifications/NotificationKpis.razor | 75 +++++++++- .../INotificationOutboxRepository.cs | 13 ++ .../Repositories/ISiteCallAuditRepository.cs | 15 ++ .../Messages/Audit/SiteCallQueries.cs | 21 +++ .../Notification/NotificationOutboxQueries.cs | 20 +++ .../Types/Audit/SiteCallNodeKpiSnapshot.cs | 37 +++++ .../NodeNotificationKpiSnapshot.cs | 30 ++++ .../CommunicationService.cs | 31 +++++ .../NotificationOutboxRepository.cs | 73 ++++++++++ .../Repositories/SiteCallAuditRepository.cs | 71 ++++++++++ .../NotificationOutboxActor.cs | 33 +++++ .../SiteCallAuditActor.cs | 42 ++++++ ...ditLogIngestActorCombinedTelemetryTests.cs | 6 + .../Pages/HealthPageTests.cs | 8 +- .../Pages/NotificationKpisPageTests.cs | 7 +- ...ficationOutboxRepositoryPerNodeKpiTests.cs | 128 ++++++++++++++++++ .../SiteCallAuditRepositoryTests.cs | 48 +++++++ .../NotificationOutboxActorQueryTests.cs | 46 +++++++ .../SiteCallAuditActorTests.cs | 45 ++++++ .../SiteCallAuditPurgeTests.cs | 5 + .../SiteCallAuditReconciliationTests.cs | 4 + .../SiteCallRelayTests.cs | 4 + 25 files changed, 840 insertions(+), 6 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallNodeKpiSnapshot.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Notifications/NodeNotificationKpiSnapshot.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor index 9c6622c8..58c6a700 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor @@ -58,3 +58,31 @@ {
Site Call KPIs unavailable: @ErrorMessage
} +@* ── Per-node stuck/parked sub-table (T6: M5.2 per-node stuck-count KPIs) ── *@ +@if (HasNodeBreakdown) +{ +
+
+ By node +
+ + + + + + + + + + @foreach (var n in PerNodeSnapshots!) + { + + + + + + } + +
NodeStuckParked
@n.SourceNode@n.StuckCount@n.ParkedCount
+
+} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs index 9e6d6fdf..6a99e091 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health; @@ -59,6 +60,24 @@ public partial class SiteCallKpiTiles /// [Parameter] public string? ErrorMessage { get; set; } + /// + /// Optional per-node KPI breakdown (T6: M5.2 per-node stuck-count KPIs). + /// When non-null and non-empty, a compact node-level stuck/parked sub-table + /// is rendered below the main tiles. null means the parent has not + /// loaded it yet or has opted out — the sub-table is suppressed entirely. + /// + [Parameter] public IReadOnlyList? PerNodeSnapshots { get; set; } + + /// + /// True when is a successful query result. + /// Used to suppress the sub-table on a load failure. + /// + [Parameter] public bool PerNodeAvailable { get; set; } + + /// Whether the per-node sub-table has data to render. + internal bool HasNodeBreakdown => + PerNodeAvailable && PerNodeSnapshots is { Count: > 0 }; + // ── Buffered tile ─────────────────────────────────────────────────────── private string BufferedDisplay => diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor index f597013a..f53d09a7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor @@ -9,6 +9,7 @@ @using ZB.MOM.WW.ScadaBridge.HealthMonitoring @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit @using ZB.MOM.WW.ScadaBridge.Communication @implements IDisposable @inject ICentralHealthAggregator HealthAggregator @@ -65,7 +66,9 @@ (buffered / stuck / parked). Refreshed alongside the site states. *@ + ErrorMessage="@_siteCallKpiError" + PerNodeSnapshots="@_siteCallNodeKpis" + PerNodeAvailable="@_siteCallNodeKpiAvailable" /> @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel (volume / error rate / backlog). Refreshed alongside the site states. *@ @@ -378,6 +381,12 @@ private bool _siteCallKpiAvailable; private string? _siteCallKpiError; + // Per-node Site Call KPI breakdown (T6: M5.2 per-node stuck-count KPIs). + // Passed to SiteCallKpiTiles as an optional sub-table. + private IReadOnlyList _siteCallNodeKpis = + Array.Empty(); + private bool _siteCallNodeKpiAvailable; + private static bool SiteHasActiveErrors(SiteHealthState state) { var report = state.LatestReport; @@ -415,7 +424,7 @@ { _siteStates = HealthAggregator.GetAllSiteStates(); await LoadOutboxKpis(); - await LoadSiteCallKpis(); + await Task.WhenAll(LoadSiteCallKpis(), LoadSiteCallNodeKpis()); await LoadAuditKpis(); } @@ -474,6 +483,30 @@ } } + // Per-node site-call KPI loader (T6: M5.2). Best-effort; a fault silently + // suppresses the per-node sub-table rather than degrading the dashboard. + private async Task LoadSiteCallNodeKpis() + { + try + { + var response = await CommunicationService.GetPerNodeSiteCallKpisAsync( + new PerNodeSiteCallKpiRequest(Guid.NewGuid().ToString("N"))); + if (response.Success) + { + _siteCallNodeKpis = response.Nodes; + _siteCallNodeKpiAvailable = true; + } + else + { + _siteCallNodeKpiAvailable = false; + } + } + catch + { + _siteCallNodeKpiAvailable = false; + } + } + // Tiles show the numeric KPI when available, or an em dash when the outbox // KPI query failed — matching how the page renders other unavailable data. private string OutboxTileValue(int value) => diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor index 7204e453..306d67ef 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationKpis.razor @@ -69,6 +69,51 @@ } + @* ── Per-node breakdown (T6: 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) + { + + + + + + + + + } + +
NodeQueue DepthStuckParkedDelivered (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) @@ -124,6 +169,10 @@ private IReadOnlyList _perSite = Array.Empty(); private string? _perSiteError; + // ── Per-node (T6: M5.2 per-node stuck-count KPIs) ── + private IReadOnlyList _perNode = Array.Empty(); + private string? _perNodeError; + private bool _loading; protected override async Task OnInitializedAsync() @@ -144,9 +193,9 @@ private async Task RefreshAll() { _loading = true; - // Race-free despite both tasks mutating component fields: Blazor Server runs + // 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()); + await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis()); _loading = false; } @@ -194,6 +243,28 @@ } } + 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; diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs index 45e38b0d..fa8299de 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs @@ -100,6 +100,19 @@ public interface INotificationOutboxRepository Task> ComputePerSiteKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default); + /// + /// Computes a point-in-time per originating node. + /// Nodes with no notification rows at all are omitted; rows with a NULL + /// SourceNode are excluded. The stuck and delivered cutoffs are supplied by the + /// caller; the current time used for OldestPendingAge is captured inside the method. + /// + /// The time threshold for marking notifications as stuck. + /// The time threshold for counting delivered notifications. + /// Cancellation token. + /// A list of per-node KPI snapshots, ordered by node name. + Task> ComputePerNodeKpisAsync( + DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default); + /// /// Persists pending changes tracked on the underlying context. Use this when staging /// multiple changes for a single commit; the individual mutating methods on this diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs index 87f949f5..c8f6cea2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs @@ -107,4 +107,19 @@ public interface ISiteCallAuditRepository DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default); + + /// + /// Computes a point-in-time per originating + /// node. Nodes with no SiteCalls rows at all are omitted; rows with a + /// NULL SourceNode are excluded. The stuck cutoff and interval + /// bounds are interpreted as in . + /// + /// UTC threshold for classifying a row as stuck. + /// UTC start of the delivered/failed interval window. + /// Cancellation token. + /// A task that resolves to a per-node KPI list; nodes with no rows are omitted. + Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, + DateTime intervalSince, + CancellationToken ct = default); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs index 0db455b9..0bb7a680 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs @@ -164,3 +164,24 @@ public sealed record PerSiteSiteCallKpiResponse( bool Success, string? ErrorMessage, IReadOnlyList Sites); + +/// +/// Site Calls UI -> Central: request for the per-node SiteCalls +/// KPI breakdown. Mirrors but groups +/// by SourceNode instead of SourceSite. Additive — does not +/// change per-site behaviour. +/// +public sealed record PerNodeSiteCallKpiRequest( + string CorrelationId); + +/// +/// Central -> Site Calls UI: per-node KPI breakdown for the Site Calls KPIs +/// page. On a repository fault is false, +/// carries the cause, and is empty. +/// Nodes with a NULL SourceNode are omitted. +/// +public sealed record PerNodeSiteCallKpiResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + IReadOnlyList Nodes); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs index 464e6059..8f2e4abe 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs @@ -159,3 +159,23 @@ public record PerSiteNotificationKpiResponse( bool Success, string? ErrorMessage, IReadOnlyList Sites); + +/// +/// Outbox UI -> Central: request for the per-node notification outbox KPI breakdown. +/// Mirrors but groups by SourceNode +/// instead of SourceSiteId. Additive — does not change per-site behaviour. +/// +public record PerNodeNotificationKpiRequest( + string CorrelationId); + +/// +/// Central -> Outbox UI: per-node KPI breakdown for the Notification KPIs page. +/// On a repository fault is false, +/// carries the cause, and is empty. Nodes with a NULL +/// SourceNode are omitted. +/// +public record PerNodeNotificationKpiResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + IReadOnlyList Nodes); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallNodeKpiSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallNodeKpiSnapshot.cs new file mode 100644 index 00000000..dc9bb2f7 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallNodeKpiSnapshot.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; + +/// +/// Point-in-time SiteCalls metrics scoped to a single originating node. The +/// per-node counterpart of ; surfaced in the +/// per-node breakdown table on the Site Calls KPIs page. Mirrors +/// . +/// +/// +/// The node identifier these metrics are scoped to (e.g. node-a, +/// node-b). Rows with a NULL SourceNode are omitted. +/// +/// Count of this node's non-terminal rows (TerminalAtUtc IS NULL). +/// Count of this node's rows in the Parked status. +/// +/// Count of this node's Failed rows whose TerminalAtUtc is at or +/// after the "since" timestamp. +/// +/// +/// Count of this node's Delivered rows whose TerminalAtUtc is at +/// or after the "since" timestamp. +/// +/// +/// Age of this node's oldest non-terminal row, or null when it has none. +/// +/// +/// Count of this node's non-terminal rows whose CreatedAtUtc is older +/// than the stuck cutoff. +/// +public sealed record SiteCallNodeKpiSnapshot( + string SourceNode, + int BufferedCount, + int ParkedCount, + int FailedLastInterval, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge, + int StuckCount); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Notifications/NodeNotificationKpiSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Notifications/NodeNotificationKpiSnapshot.cs new file mode 100644 index 00000000..4ea22102 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Notifications/NodeNotificationKpiSnapshot.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications; + +/// +/// Point-in-time notification-outbox metrics scoped to a single originating node. +/// The per-node counterpart of ; surfaced +/// in the per-node breakdown table on the Notification KPIs page. +/// +/// +/// The node identifier these metrics are scoped to (e.g. node-a, +/// node-b). Rows with a NULL SourceNode are omitted. +/// +/// Count of this node's non-terminal rows (Pending + Retrying). +/// +/// Count of this node's non-terminal rows whose CreatedAt is older than the stuck cutoff. +/// +/// Count of this node's rows in the Parked status. +/// +/// Count of this node's Delivered rows whose DeliveredAt is at or after the +/// "delivered since" timestamp. +/// +/// +/// Age of this node's oldest non-terminal row, or null when it has none. +/// +public record NodeNotificationKpiSnapshot( + string SourceNode, + int QueueDepth, + int StuckCount, + int ParkedCount, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index 30bd7457..7cec3a3e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -525,6 +525,22 @@ public class CommunicationService request, _options.QueryTimeout, cancellationToken); } + /// + /// Gets per-node KPI metrics for the notification outbox. + /// Groups by SourceNode (e.g. node-a/node-b); rows with + /// a NULL node are omitted. Additive alongside + /// . + /// + /// The per-node notification KPI request. + /// Cancellation token. + /// The per-node notification KPI response. + public async Task GetPerNodeNotificationKpisAsync( + PerNodeNotificationKpiRequest request, CancellationToken cancellationToken = default) + { + return await GetNotificationOutbox().Ask( + request, _options.QueryTimeout, cancellationToken); + } + // ── Site Call Audit (central-local actor — Asked directly, no SiteEnvelope) ── /// @@ -579,6 +595,21 @@ public class CommunicationService request, _options.QueryTimeout, cancellationToken); } + /// + /// Gets per-node KPI metrics for site calls. Groups by SourceNode + /// (e.g. node-a/node-b); rows with a NULL node are + /// omitted. Additive alongside . + /// + /// The per-node site call KPI request. + /// Cancellation token. + /// The per-node site call KPI response. + public async Task GetPerNodeSiteCallKpisAsync( + PerNodeSiteCallKpiRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } + /// /// Task 5 (#22): relays an operator Retry of a parked cached call to its /// owning site. The SiteCallAuditActor is Asked directly (it is diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs index a7eb6174..6845bb73 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs @@ -300,6 +300,63 @@ VALUES : null)).ToList(); } + /// + public async Task> ComputePerNodeKpisAsync( + DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + + // Exclude rows with NULL SourceNode (legacy / unstamped) — per-node KPIs + // are only meaningful when the node identity is known. + var queueDepth = await CountByNodeAsync( + n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) + && n.SourceNode != null, + cancellationToken); + + var stuck = await CountByNodeAsync( + n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) + && n.CreatedAt < stuckCutoff + && n.SourceNode != null, + cancellationToken); + + var parked = await CountByNodeAsync( + n => n.Status == NotificationStatus.Parked && n.SourceNode != null, + cancellationToken); + + var delivered = await CountByNodeAsync( + n => n.Status == NotificationStatus.Delivered + && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince + && n.SourceNode != null, + cancellationToken); + + // Oldest non-terminal CreatedAt per node — same in-memory reduction + // pattern as ComputePerSiteKpisAsync (DateTimeOffset converter makes + // a SQL Min awkward). + var oldest = (await _context.Notifications + .Where(n => (n.Status == NotificationStatus.Pending + || n.Status == NotificationStatus.Retrying) + && n.SourceNode != null) + .Select(n => new { n.SourceNode, n.CreatedAt }) + .ToListAsync(cancellationToken)) + .GroupBy(x => x.SourceNode!) + .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt)); + + var nodeNames = queueDepth.Keys + .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys) + .Distinct() + .OrderBy(n => n, StringComparer.Ordinal); + + return nodeNames.Select(node => new NodeNotificationKpiSnapshot( + SourceNode: node, + QueueDepth: queueDepth.GetValueOrDefault(node), + StuckCount: stuck.GetValueOrDefault(node), + ParkedCount: parked.GetValueOrDefault(node), + DeliveredLastInterval: delivered.GetValueOrDefault(node), + OldestPendingAge: oldest.TryGetValue(node, out var createdAt) + ? now - createdAt + : null)).ToList(); + } + /// Counts notification rows matching , grouped by source site. private async Task> CountBySiteAsync( System.Linq.Expressions.Expression> predicate, @@ -312,6 +369,22 @@ VALUES .ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken); } + /// + /// Counts notification rows matching , grouped by source node. + /// Only rows with a non-null SourceNode should be included; the predicate is + /// responsible for enforcing that guard. + /// + private async Task> CountByNodeAsync( + System.Linq.Expressions.Expression> predicate, + CancellationToken cancellationToken) + { + return await _context.Notifications + .Where(predicate) + .GroupBy(n => n.SourceNode!) + .Select(g => new { Node = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Node, x => x.Count, cancellationToken); + } + /// public async Task SaveChangesAsync(CancellationToken cancellationToken = default) => await _context.SaveChangesAsync(cancellationToken); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index d37c7f83..fd89e192 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -324,6 +324,61 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;"; StuckCount: stuck.GetValueOrDefault(site))).ToList(); } + /// + public async Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + + // Exclude rows with NULL SourceNode — per-node KPIs are only meaningful + // when the node identity is known. Each predicate guards n.SourceNode != null + // so the GROUP BY key is always non-null. + var buffered = await CountByNodeAsync( + s => s.TerminalAtUtc == null && s.SourceNode != null, ct); + + var parked = await CountByNodeAsync( + s => s.Status == StatusParked && s.SourceNode != null, ct); + + var failed = await CountByNodeAsync( + s => s.Status == StatusFailed + && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince + && s.SourceNode != null, ct); + + var delivered = await CountByNodeAsync( + s => s.Status == StatusDelivered + && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince + && s.SourceNode != null, ct); + + var stuck = await CountByNodeAsync( + s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff + && s.SourceNode != null, ct); + + // Oldest non-terminal CreatedAtUtc per node — server-side GROUP BY MIN. + var oldest = (await _context.SiteCalls + .Where(s => s.TerminalAtUtc == null && s.SourceNode != null) + .GroupBy(s => s.SourceNode!) + .Select(g => new { Node = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) }) + .ToListAsync(ct)) + .ToDictionary(x => x.Node, x => x.Oldest); + + var nodeNames = buffered.Keys + .Concat(parked.Keys).Concat(failed.Keys) + .Concat(delivered.Keys).Concat(stuck.Keys) + .Distinct() + .OrderBy(n => n, StringComparer.Ordinal); + + return nodeNames.Select(node => new SiteCallNodeKpiSnapshot( + SourceNode: node, + BufferedCount: buffered.GetValueOrDefault(node), + ParkedCount: parked.GetValueOrDefault(node), + FailedLastInterval: failed.GetValueOrDefault(node), + DeliveredLastInterval: delivered.GetValueOrDefault(node), + OldestPendingAge: oldest.TryGetValue(node, out var createdAt) + ? now - createdAt + : null, + StuckCount: stuck.GetValueOrDefault(node))).ToList(); + } + /// Counts SiteCalls rows matching , grouped by source site. private async Task> CountBySiteAsync( System.Linq.Expressions.Expression> predicate, @@ -336,6 +391,22 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;"; .ToDictionaryAsync(x => x.Site, x => x.Count, ct); } + /// + /// Counts SiteCalls rows matching , grouped by source node. + /// Only rows with a non-null SourceNode should be included; the predicate is + /// responsible for enforcing that guard. + /// + private async Task> CountByNodeAsync( + System.Linq.Expressions.Expression> predicate, + CancellationToken ct) + { + return await _context.SiteCalls + .Where(predicate) + .GroupBy(s => s.SourceNode!) + .Select(g => new { Node = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Node, x => x.Count, ct); + } + private static int GetRankOrThrow(string status) { if (!StatusRank.TryGetValue(status, out var rank)) diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs index 8689697c..93428cc8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs @@ -122,6 +122,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers Receive(HandleDiscard); Receive(HandleKpiRequest); Receive(HandlePerSiteKpiRequest); + Receive(HandlePerNodeKpiRequest); } /// @@ -1081,6 +1082,38 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers return new PerSiteNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, sites); } + /// + /// Handles a per-node KPI request, computing the per-source-node outbox metrics with the + /// same stuck cutoff and delivered window as . Additive + /// alongside — does not change per-site behaviour. + /// + private void HandlePerNodeKpiRequest(PerNodeNotificationKpiRequest request) + { + var sender = Sender; + var now = DateTimeOffset.UtcNow; + var stuckCutoff = StuckCutoff(now); + var deliveredSince = now - _options.DeliveredKpiWindow; + + ComputePerNodeKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo( + sender, + success: response => response, + failure: ex => new PerNodeNotificationKpiResponse( + request.CorrelationId, + Success: false, + ErrorMessage: ex.GetBaseException().Message, + Nodes: Array.Empty())); + } + + private async Task ComputePerNodeKpisAsync( + string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince) + { + using var scope = _serviceProvider.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var nodes = await repository.ComputePerNodeKpisAsync(stuckCutoff, deliveredSince); + + return new PerNodeNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, nodes); + } + /// /// The instant before which a still-pending notification counts as stuck — /// offset back by . diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs index b89ae01a..a8735131 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs @@ -239,6 +239,7 @@ public class SiteCallAuditActor : ReceiveActor Receive(HandleDetail); Receive(HandleKpi); Receive(HandlePerSiteKpi); + Receive(HandlePerNodeKpi); // Task 5 (#22): central→site Retry/Discard relay for parked cached calls. Receive(msg => @@ -817,6 +818,47 @@ public class SiteCallAuditActor : ReceiveActor } } + /// + /// Handles a per-node KPI request, using the same stuck cutoff and + /// interval bound as . Additive alongside + /// — does not change per-site behaviour. + /// + private void HandlePerNodeKpi(PerNodeSiteCallKpiRequest request) + { + var sender = Sender; + var now = DateTime.UtcNow; + var stuckCutoff = now - _options.StuckAgeThreshold; + var intervalSince = now - _options.KpiInterval; + + PerNodeKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo( + sender, + success: response => response, + failure: ex => new PerNodeSiteCallKpiResponse( + request.CorrelationId, + Success: false, + ErrorMessage: ex.GetBaseException().Message, + Nodes: Array.Empty())); + } + + private async Task PerNodeKpiAsync( + string correlationId, DateTime stuckCutoff, DateTime intervalSince) + { + var (scope, repository) = ResolveRepository(); + try + { + var nodes = await repository + .ComputePerNodeKpisAsync(stuckCutoff, intervalSince) + .ConfigureAwait(false); + + return new PerNodeSiteCallKpiResponse( + correlationId, Success: true, ErrorMessage: null, nodes); + } + finally + { + scope?.Dispose(); + } + } + // ── Task 5: central→site Retry/Discard relay ── /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs index 4a53645c..c722b969 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs @@ -362,6 +362,9 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); } /// @@ -399,5 +402,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index 0946d183..66aff075 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health; @@ -232,13 +233,18 @@ public class HealthPageTests : BunitContext /// /// Stand-in for the Site Call Audit actor. Replies to the KPI request with - /// the test's currently-scripted response. + /// the test's currently-scripted response. Also handles the per-node KPI + /// request (T6: M5.2) with an empty-nodes success reply so the Health page + /// can complete initialization without a 30-second Ask timeout. /// private sealed class ScriptedSiteCallAuditActor : ReceiveActor { public ScriptedSiteCallAuditActor(HealthPageTests test) { Receive(_ => Sender.Tell(test._siteCallKpiReply)); + Receive(req => Sender.Tell( + new PerNodeSiteCallKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null, + Nodes: Array.Empty()))); } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs index ffc1c01b..ce7e3a40 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -153,7 +153,9 @@ public class NotificationKpisPageTests : BunitContext /// /// Stand-in for the notification-outbox actor. Replies to each KPI message - /// type with the test's currently-scripted response. + /// type with the test's currently-scripted response. Also handles the per-node + /// KPI request (T6: M5.2) with an empty-nodes success reply so the page can + /// complete initialization without a 30-second Ask timeout. /// private sealed class ScriptedOutboxActor : ReceiveActor { @@ -161,6 +163,9 @@ public class NotificationKpisPageTests : BunitContext { Receive(_ => Sender.Tell(test._kpiReply)); Receive(_ => Sender.Tell(test._perSiteReply)); + Receive(req => Sender.Tell( + new PerNodeNotificationKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null, + Nodes: Array.Empty()))); } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs new file mode 100644 index 00000000..0d2737f3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerNodeKpiTests.cs @@ -0,0 +1,128 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; + +// Coverage for per-node KPI aggregation in the Notification Outbox repository +// (T6: M5.2 per-node stuck-count KPIs). +public class NotificationOutboxRepositoryPerNodeKpiTests +{ + private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext(); + + private static Notification NewNotification( + string sourceSiteId, + NotificationStatus status, + DateTimeOffset createdAt, + DateTimeOffset? deliveredAt = null, + string? sourceNode = null) + { + return new Notification( + Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId) + { + Status = status, + CreatedAt = createdAt, + DeliveredAt = deliveredAt, + SourceNode = sourceNode, + }; + } + + [Fact] + public async Task ComputePerNodeKpisAsync_AggregatesMetricsPerNode() + { + await using var ctx = NewContext(); + var now = DateTimeOffset.UtcNow; + + // node-a: 1 pending (stuck, created 20m ago), 1 parked + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, + createdAt: now.AddMinutes(-20), sourceNode: "node-a")); + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked, + createdAt: now.AddMinutes(-5), sourceNode: "node-a")); + // node-b: 1 delivered in-window, 1 pending (fresh) + ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, + createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2), sourceNode: "node-b")); + ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending, + createdAt: now.AddMinutes(-1), sourceNode: "node-b")); + // NULL SourceNode — must be excluded from per-node results + ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Pending, + createdAt: now.AddMinutes(-5), sourceNode: null)); + await ctx.SaveChangesAsync(); + + var repo = new NotificationOutboxRepository(ctx); + var result = await repo.ComputePerNodeKpisAsync( + stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30)); + + // Only node-a and node-b — the null-node row is excluded. + Assert.Equal(2, result.Count); + + var a = result.Single(n => n.SourceNode == "node-a"); + Assert.Equal(1, a.QueueDepth); + Assert.Equal(1, a.StuckCount); + Assert.Equal(1, a.ParkedCount); + Assert.Equal(0, a.DeliveredLastInterval); + Assert.NotNull(a.OldestPendingAge); + + var b = result.Single(n => n.SourceNode == "node-b"); + Assert.Equal(1, b.QueueDepth); + Assert.Equal(0, b.StuckCount); + Assert.Equal(0, b.ParkedCount); + Assert.Equal(1, b.DeliveredLastInterval); + Assert.NotNull(b.OldestPendingAge); + } + + [Fact] + public async Task ComputePerNodeKpisAsync_ExcludesNullSourceNode() + { + await using var ctx = NewContext(); + var now = DateTimeOffset.UtcNow; + + // Only null-node rows — result must be empty. + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, + createdAt: now.AddMinutes(-5), sourceNode: null)); + await ctx.SaveChangesAsync(); + + var repo = new NotificationOutboxRepository(ctx); + var result = await repo.ComputePerNodeKpisAsync( + stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30)); + + Assert.Empty(result); + } + + [Fact] + public async Task ComputePerNodeKpisAsync_ReturnsEmpty_WhenNoNotifications() + { + await using var ctx = NewContext(); + var repo = new NotificationOutboxRepository(ctx); + var result = await repo.ComputePerNodeKpisAsync( + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30)); + Assert.Empty(result); + } + + [Fact] + public async Task ComputePerNodeKpisAsync_OldestPendingAge_ReflectsOlderRow() + { + await using var ctx = NewContext(); + var now = DateTimeOffset.UtcNow; + + // node-a: pending 90m ago, retrying 40m ago. + // OldestPendingAge must reflect the 90m row. + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, + createdAt: now.AddMinutes(-90), sourceNode: "node-a")); + ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Retrying, + createdAt: now.AddMinutes(-40), sourceNode: "node-a")); + await ctx.SaveChangesAsync(); + + var repo = new NotificationOutboxRepository(ctx); + var result = await repo.ComputePerNodeKpisAsync( + stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30)); + + var a = result.Single(n => n.SourceNode == "node-a"); + Assert.Equal(2, a.QueueDepth); + Assert.Equal(2, a.StuckCount); + Assert.NotNull(a.OldestPendingAge); + Assert.True(a.OldestPendingAge >= TimeSpan.FromMinutes(85), + $"expected OldestPendingAge >= 85m, got {a.OldestPendingAge}"); + Assert.True(a.OldestPendingAge < TimeSpan.FromMinutes(95), + $"expected OldestPendingAge < 95m, got {a.OldestPendingAge}"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index cef28b81..2a3e07ea 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -497,6 +497,54 @@ public class SiteCallAuditRepositoryTests : IClassFixture Assert.Null(b.OldestPendingAge); } + [SkippableFact] + public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Use unique site + node combos to isolate from other tests running + // concurrently on the shared MsSql fixture. + var nodeId = "node-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var nodeB = nodeId + "-b"; + await using var context = CreateContext(); + var repo = new SiteCallAuditRepository(context); + + var now = DateTime.UtcNow; + var stuckCutoff = now.AddMinutes(-10); + var intervalSince = now.AddHours(-1); + + // nodeId: 2 buffered (one stuck), 1 parked. + await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted", + createdAtUtc: now.AddMinutes(-30), sourceNode: nodeId)); + await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted", + createdAtUtc: now.AddMinutes(-2), sourceNode: nodeId)); + await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Parked", + createdAtUtc: now.AddMinutes(-5), terminal: true, sourceNode: nodeId)); + // nodeB: 1 delivered within interval only. + await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Delivered", + createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1), + terminal: true, terminalAtUtc: now.AddMinutes(-1), sourceNode: nodeB)); + // Null SourceNode row — must NOT appear in per-node results. + await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted", + createdAtUtc: now.AddMinutes(-3), sourceNode: null)); + + var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, intervalSince); + + var na = Assert.Single(perNode, n => n.SourceNode == nodeId); + Assert.Equal(2, na.BufferedCount); + Assert.Equal(1, na.ParkedCount); + Assert.Equal(1, na.StuckCount); + Assert.NotNull(na.OldestPendingAge); + + var nb = Assert.Single(perNode, n => n.SourceNode == nodeB); + Assert.Equal(0, nb.BufferedCount); + Assert.Equal(1, nb.DeliveredLastInterval); + Assert.Null(nb.OldestPendingAge); + + // Null-node row must be absent. + Assert.DoesNotContain(perNode, n => n.SourceNode is null); + } + // --- helpers ------------------------------------------------------------ private ScadaBridgeDbContext CreateContext() diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index b7b2b4f2..a0fc75de 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -495,4 +495,50 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Contains("db down", response.ErrorMessage); Assert.Empty(response.Sites); } + + // ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ────────────────── + + [Fact] + public void PerNodeKpiRequest_RepliesWithPerNodeSnapshots() + { + _repository.ComputePerNodeKpisAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("node-a", QueueDepth: 3, StuckCount: 1, ParkedCount: 0, + DeliveredLastInterval: 5, OldestPendingAge: TimeSpan.FromMinutes(12)), + }); + var actor = CreateActor(); + + actor.Tell(new PerNodeNotificationKpiRequest("corr-pn"), TestActor); + + var response = ExpectMsg(); + Assert.True(response.Success); + Assert.Null(response.ErrorMessage); + Assert.Equal("corr-pn", response.CorrelationId); + Assert.Single(response.Nodes); + Assert.Equal("node-a", response.Nodes[0].SourceNode); + Assert.Equal(1, response.Nodes[0].StuckCount); + + _repository.Received(1).ComputePerNodeKpisAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public void PerNodeKpiRequest_RepositoryFault_RepliesUnsuccessful() + { + _repository.ComputePerNodeKpisAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("node-kpi db down")); + var actor = CreateActor(); + + actor.Tell(new PerNodeNotificationKpiRequest("corr-pn"), TestActor); + + var response = ExpectMsg(); + Assert.False(response.Success); + Assert.Equal("corr-pn", response.CorrelationId); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("node-kpi db down", response.ErrorMessage); + Assert.Empty(response.Nodes); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs index 0469e12a..dc401484 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs @@ -594,6 +594,43 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + + var myNode = Assert.Single(response.Nodes, n => n.SourceNode == nodeId); + Assert.Equal(1, myNode.BufferedCount); + Assert.Equal(1, myNode.ParkedCount); + Assert.Equal(1, myNode.StuckCount); + Assert.NotNull(myNode.OldestPendingAge); + } + [SkippableFact] public async Task PerSiteSiteCallKpiRequest_ScopesCountsToEachSite() { @@ -745,6 +782,10 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); } /// @@ -790,5 +831,9 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); + + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs index 6352ddec..78b63f1b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs @@ -76,6 +76,10 @@ public class SiteCallAuditPurgeTests : TestKit public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// Repository whose purge always throws — to prove continue-on-error keeps the singleton alive. @@ -94,6 +98,7 @@ public class SiteCallAuditPurgeTests : TestKit public Task> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); public Task ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0)); public Task> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + public Task> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options) => diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs index ac2f86b0..22244e50 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditReconciliationTests.cs @@ -142,6 +142,10 @@ public class SiteCallAuditReconciliationTests : TestKit public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } private IActorRef CreateActor( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs index 924ed941..582b799e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallRelayTests.cs @@ -50,6 +50,10 @@ public class SiteCallRelayTests : TestKit public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => throw new InvalidOperationException("relay must not compute per-site KPIs"); + + public Task> ComputePerNodeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + throw new InvalidOperationException("relay must not compute per-node KPIs"); } ///