From b9c017136d857123c1b614f1c0af9dc4b8d618cd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 18:04:59 -0400 Subject: [PATCH] feat(ui): add Node column + filter to NotificationOutbox grid --- .../Notifications/NotificationReport.razor | 21 +++++++++- .../Notification/NotificationOutboxQueries.cs | 6 ++- .../Notifications/NotificationOutboxFilter.cs | 8 +++- .../NotificationOutboxRepository.cs | 7 ++++ .../NotificationOutboxActor.cs | 6 ++- .../NotificationOutboxActorQueryTests.cs | 39 +++++++++++++++++++ 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 44798ce..207f402 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -60,6 +60,16 @@ + @* Task 16: free-text Node filter — exact match against the + notification's SourceNode column. Sites + central nodes + both flow through this single input. *@ +
+ + +
Status Retries Source site + Node Created Delivered Actions @@ -162,6 +173,7 @@ @n.RetryCount @SiteName(n.SourceSiteId) + @(n.SourceNode ?? "—") @@ -253,6 +265,9 @@
Source site
@SiteName(d.SourceSiteId)
+
Source node
+
@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)
+
Source instance
@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)
@@ -372,6 +387,7 @@ private string _siteFilter = string.Empty; private string _listFilter = string.Empty; private string _subjectFilter = string.Empty; + private string _nodeFilter = string.Empty; private bool _stuckOnly; private DateTime? _fromFilter; private DateTime? _toFilter; @@ -422,7 +438,8 @@ From: ToUtc(_fromFilter), To: ToUtc(_toFilter), PageNumber: _pageNumber, - PageSize: _pageSize); + PageSize: _pageSize, + SourceNodeFilter: NullIfEmpty(_nodeFilter)); var response = await CommunicationService.QueryNotificationOutboxAsync(request); if (response.Success) @@ -597,6 +614,7 @@ _siteFilter = string.Empty; _listFilter = string.Empty; _subjectFilter = string.Empty; + _nodeFilter = string.Empty; _stuckOnly = false; _fromFilter = null; _toFilter = null; @@ -608,6 +626,7 @@ !string.IsNullOrEmpty(_siteFilter) || !string.IsNullOrEmpty(_listFilter) || !string.IsNullOrEmpty(_subjectFilter) || + !string.IsNullOrEmpty(_nodeFilter) || _stuckOnly || _fromFilter != null || _toFilter != null; diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs index 12151d2..27bd888 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs @@ -17,7 +17,8 @@ public record NotificationOutboxQueryRequest( DateTimeOffset? From, DateTimeOffset? To, int PageNumber, - int PageSize); + int PageSize, + string? SourceNodeFilter = null); /// /// A single notification row summarised for outbox UI display. @@ -34,7 +35,8 @@ public record NotificationSummary( string? SourceInstanceId, DateTimeOffset CreatedAt, DateTimeOffset? DeliveredAt, - bool IsStuck); + bool IsStuck, + string? SourceNode = null); /// /// Central -> Outbox UI: paginated response for a . diff --git a/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs b/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs index 0b405b1..dc3e1eb 100644 --- a/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs +++ b/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs @@ -18,6 +18,11 @@ namespace ScadaLink.Commons.Types.Notifications; /// Rows with CreatedAt older than this count as stuck. /// Inclusive lower bound on CreatedAt. /// Inclusive upper bound on CreatedAt. +/// +/// Restrict to notifications originating at a specific cluster node (e.g. +/// "central-a", "site-plant-a-node-a"). Exact match; null +/// means "do not constrain". +/// public record NotificationOutboxFilter( NotificationStatus? Status = null, NotificationType? Type = null, @@ -27,4 +32,5 @@ public record NotificationOutboxFilter( bool StuckOnly = false, DateTimeOffset? StuckCutoff = null, DateTimeOffset? From = null, - DateTimeOffset? To = null); + DateTimeOffset? To = null, + string? SourceNode = null); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs index 78bab14..0a8a0c3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs @@ -83,6 +83,13 @@ public class NotificationOutboxRepository : INotificationOutboxRepository query = query.Where(n => n.SourceSiteId == filter.SourceSiteId); } + // Task 16: SourceNode is exact-match like SourceSiteId. Rows with NULL + // SourceNode (legacy / unconfigured) are excluded when the filter is set. + if (!string.IsNullOrEmpty(filter.SourceNode)) + { + query = query.Where(n => n.SourceNode == filter.SourceNode); + } + if (!string.IsNullOrEmpty(filter.ListName)) { query = query.Where(n => n.ListName == filter.ListName); diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 4d2cc85..c7a91ef 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -626,7 +626,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers StuckOnly: request.StuckOnly, StuckCutoff: request.StuckOnly ? StuckCutoff(now) : null, From: request.From, - To: request.To); + To: request.To, + SourceNode: request.SourceNodeFilter); using var scope = _serviceProvider.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -646,7 +647,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers row.SourceInstanceId, row.CreatedAt, row.DeliveredAt, - IsStuck: IsStuck(row, stuckCutoff))) + IsStuck: IsStuck(row, stuckCutoff), + SourceNode: row.SourceNode)) .ToList(); return new NotificationOutboxQueryResponse( diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index 9c2860b..a7fa0e4 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -149,6 +149,45 @@ public class NotificationOutboxActorQueryTests : TestKit 1, 50, Arg.Any()); } + [Fact] + public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary() + { + // Task 16: the Notifications page's new Node filter input pushes a + // value into NotificationOutboxQueryRequest.SourceNodeFilter; the actor + // must thread it onto NotificationOutboxFilter.SourceNode AND mirror + // the row's SourceNode column onto the response summaries. + var row = MakeNotification(status: NotificationStatus.Pending); + row.SourceNode = "central-a"; + _repository.QueryAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(((IReadOnlyList)new[] { row }, 1)); + var actor = CreateActor(); + + actor.Tell( + new NotificationOutboxQueryRequest( + CorrelationId: "corr-node", + StatusFilter: null, + TypeFilter: null, + SourceSiteFilter: null, + ListNameFilter: null, + StuckOnly: false, + SubjectKeyword: null, + From: null, + To: null, + PageNumber: 1, + PageSize: 50, + SourceNodeFilter: "central-a"), + TestActor); + + var response = ExpectMsg(); + Assert.True(response.Success); + Assert.Equal("central-a", response.Notifications.Single().SourceNode); + + _repository.Received(1).QueryAsync( + Arg.Is(f => f.SourceNode == "central-a"), + 1, 50, Arg.Any()); + } + [Fact] public void Query_RepositoryThrows_RepliesFailureWithEmptyList() {