feat(ui): add Node column + filter to NotificationOutbox grid
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"/>.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user