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"
|
<input id="no-list" type="text" class="form-control form-control-sm"
|
||||||
style="min-width: 140px;" placeholder="Any" @bind="_listFilter" />
|
style="min-width: 140px;" placeholder="Any" @bind="_listFilter" />
|
||||||
</div>
|
</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">
|
<div class="col-auto">
|
||||||
<label class="form-label small mb-1" for="no-from">From</label>
|
<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"
|
<input id="no-from" type="datetime-local" class="form-control form-control-sm"
|
||||||
@@ -131,6 +141,7 @@
|
|||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="text-end">Retries</th>
|
<th class="text-end">Retries</th>
|
||||||
<th>Source site</th>
|
<th>Source site</th>
|
||||||
|
<th>Node</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Delivered</th>
|
<th>Delivered</th>
|
||||||
<th class="text-end">Actions</th>
|
<th class="text-end">Actions</th>
|
||||||
@@ -162,6 +173,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end font-monospace">@n.RetryCount</td>
|
<td class="text-end font-monospace">@n.RetryCount</td>
|
||||||
<td><span class="small">@SiteName(n.SourceSiteId)</span></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.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
|
||||||
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
|
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
|
||||||
<td class="text-end" @ondblclick:stopPropagation="true">
|
<td class="text-end" @ondblclick:stopPropagation="true">
|
||||||
@@ -253,6 +265,9 @@
|
|||||||
<dt class="col-sm-3">Source site</dt>
|
<dt class="col-sm-3">Source site</dt>
|
||||||
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
|
<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>
|
<dt class="col-sm-3">Source instance</dt>
|
||||||
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
|
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
|
||||||
|
|
||||||
@@ -372,6 +387,7 @@
|
|||||||
private string _siteFilter = string.Empty;
|
private string _siteFilter = string.Empty;
|
||||||
private string _listFilter = string.Empty;
|
private string _listFilter = string.Empty;
|
||||||
private string _subjectFilter = string.Empty;
|
private string _subjectFilter = string.Empty;
|
||||||
|
private string _nodeFilter = string.Empty;
|
||||||
private bool _stuckOnly;
|
private bool _stuckOnly;
|
||||||
private DateTime? _fromFilter;
|
private DateTime? _fromFilter;
|
||||||
private DateTime? _toFilter;
|
private DateTime? _toFilter;
|
||||||
@@ -422,7 +438,8 @@
|
|||||||
From: ToUtc(_fromFilter),
|
From: ToUtc(_fromFilter),
|
||||||
To: ToUtc(_toFilter),
|
To: ToUtc(_toFilter),
|
||||||
PageNumber: _pageNumber,
|
PageNumber: _pageNumber,
|
||||||
PageSize: _pageSize);
|
PageSize: _pageSize,
|
||||||
|
SourceNodeFilter: NullIfEmpty(_nodeFilter));
|
||||||
|
|
||||||
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
|
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
|
||||||
if (response.Success)
|
if (response.Success)
|
||||||
@@ -597,6 +614,7 @@
|
|||||||
_siteFilter = string.Empty;
|
_siteFilter = string.Empty;
|
||||||
_listFilter = string.Empty;
|
_listFilter = string.Empty;
|
||||||
_subjectFilter = string.Empty;
|
_subjectFilter = string.Empty;
|
||||||
|
_nodeFilter = string.Empty;
|
||||||
_stuckOnly = false;
|
_stuckOnly = false;
|
||||||
_fromFilter = null;
|
_fromFilter = null;
|
||||||
_toFilter = null;
|
_toFilter = null;
|
||||||
@@ -608,6 +626,7 @@
|
|||||||
!string.IsNullOrEmpty(_siteFilter) ||
|
!string.IsNullOrEmpty(_siteFilter) ||
|
||||||
!string.IsNullOrEmpty(_listFilter) ||
|
!string.IsNullOrEmpty(_listFilter) ||
|
||||||
!string.IsNullOrEmpty(_subjectFilter) ||
|
!string.IsNullOrEmpty(_subjectFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_nodeFilter) ||
|
||||||
_stuckOnly ||
|
_stuckOnly ||
|
||||||
_fromFilter != null ||
|
_fromFilter != null ||
|
||||||
_toFilter != null;
|
_toFilter != null;
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public record NotificationOutboxQueryRequest(
|
|||||||
DateTimeOffset? From,
|
DateTimeOffset? From,
|
||||||
DateTimeOffset? To,
|
DateTimeOffset? To,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize);
|
int PageSize,
|
||||||
|
string? SourceNodeFilter = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single notification row summarised for outbox UI display.
|
/// A single notification row summarised for outbox UI display.
|
||||||
@@ -34,7 +35,8 @@ public record NotificationSummary(
|
|||||||
string? SourceInstanceId,
|
string? SourceInstanceId,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset? DeliveredAt,
|
DateTimeOffset? DeliveredAt,
|
||||||
bool IsStuck);
|
bool IsStuck,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central -> Outbox UI: paginated response for a <see cref="NotificationOutboxQueryRequest"/>.
|
/// 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="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="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
|
||||||
/// <param name="To">Inclusive upper 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(
|
public record NotificationOutboxFilter(
|
||||||
NotificationStatus? Status = null,
|
NotificationStatus? Status = null,
|
||||||
NotificationType? Type = null,
|
NotificationType? Type = null,
|
||||||
@@ -27,4 +32,5 @@ public record NotificationOutboxFilter(
|
|||||||
bool StuckOnly = false,
|
bool StuckOnly = false,
|
||||||
DateTimeOffset? StuckCutoff = null,
|
DateTimeOffset? StuckCutoff = null,
|
||||||
DateTimeOffset? From = 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);
|
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))
|
if (!string.IsNullOrEmpty(filter.ListName))
|
||||||
{
|
{
|
||||||
query = query.Where(n => n.ListName == filter.ListName);
|
query = query.Where(n => n.ListName == filter.ListName);
|
||||||
|
|||||||
@@ -626,7 +626,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
StuckOnly: request.StuckOnly,
|
StuckOnly: request.StuckOnly,
|
||||||
StuckCutoff: request.StuckOnly ? StuckCutoff(now) : null,
|
StuckCutoff: request.StuckOnly ? StuckCutoff(now) : null,
|
||||||
From: request.From,
|
From: request.From,
|
||||||
To: request.To);
|
To: request.To,
|
||||||
|
SourceNode: request.SourceNodeFilter);
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
||||||
@@ -646,7 +647,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
row.SourceInstanceId,
|
row.SourceInstanceId,
|
||||||
row.CreatedAt,
|
row.CreatedAt,
|
||||||
row.DeliveredAt,
|
row.DeliveredAt,
|
||||||
IsStuck: IsStuck(row, stuckCutoff)))
|
IsStuck: IsStuck(row, stuckCutoff),
|
||||||
|
SourceNode: row.SourceNode))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new NotificationOutboxQueryResponse(
|
return new NotificationOutboxQueryResponse(
|
||||||
|
|||||||
@@ -149,6 +149,45 @@ public class NotificationOutboxActorQueryTests : TestKit
|
|||||||
1, 50, Arg.Any<CancellationToken>());
|
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]
|
[Fact]
|
||||||
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
|
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user