feat(ui): add Node column + filter to NotificationOutbox grid

This commit is contained in:
Joseph Doherty
2026-05-23 18:04:59 -04:00
parent bb29d65a94
commit b9c017136d
6 changed files with 81 additions and 6 deletions

View File

@@ -60,6 +60,16 @@
<input id="no-list" type="text" class="form-control form-control-sm"
style="min-width: 140px;" placeholder="Any" @bind="_listFilter" />
</div>
@* Task 16: free-text Node filter — exact match against the
notification's SourceNode column. Sites + central nodes
both flow through this single input. *@
<div class="col-auto">
<label class="form-label small mb-1" for="no-node">Node</label>
<input id="no-node" type="text" class="form-control form-control-sm"
style="min-width: 140px;" placeholder="Any"
data-test="notif-filter-node"
@bind="_nodeFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="no-from">From</label>
<input id="no-from" type="datetime-local" class="form-control form-control-sm"
@@ -131,6 +141,7 @@
<th>Status</th>
<th class="text-end">Retries</th>
<th>Source site</th>
<th>Node</th>
<th>Created</th>
<th>Delivered</th>
<th class="text-end">Actions</th>
@@ -162,6 +173,7 @@
</td>
<td class="text-end font-monospace">@n.RetryCount</td>
<td><span class="small">@SiteName(n.SourceSiteId)</span></td>
<td><span class="small">@(n.SourceNode ?? "—")</span></td>
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
<td class="text-end" @ondblclick:stopPropagation="true">
@@ -253,6 +265,9 @@
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
<dt class="col-sm-3">Source node</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)</dd>
<dt class="col-sm-3">Source instance</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
@@ -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;

View File

@@ -17,7 +17,8 @@ public record NotificationOutboxQueryRequest(
DateTimeOffset? From,
DateTimeOffset? To,
int PageNumber,
int PageSize);
int PageSize,
string? SourceNodeFilter = null);
/// <summary>
/// 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);
/// <summary>
/// Central -> Outbox UI: paginated response for a <see cref="NotificationOutboxQueryRequest"/>.

View File

@@ -18,6 +18,11 @@ namespace ScadaLink.Commons.Types.Notifications;
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
/// <param name="SourceNode">
/// Restrict to notifications originating at a specific cluster node (e.g.
/// <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c>
/// means "do not constrain".
/// </param>
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);

View File

@@ -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);

View File

@@ -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<INotificationOutboxRepository>();
@@ -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(

View File

@@ -149,6 +149,45 @@ public class NotificationOutboxActorQueryTests : TestKit
1, 50, Arg.Any<CancellationToken>());
}
[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<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(((IReadOnlyList<Notification>)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<NotificationOutboxQueryResponse>();
Assert.True(response.Success);
Assert.Equal("central-a", response.Notifications.Single().SourceNode);
_repository.Received(1).QueryAsync(
Arg.Is<NotificationOutboxFilter>(f => f.SourceNode == "central-a"),
1, 50, Arg.Any<CancellationToken>());
}
[Fact]
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
{