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. *@
+
From
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()
{