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

This commit is contained in:
Joseph Doherty
2026-05-23 18:08:25 -04:00
parent b9c017136d
commit d18a6e6fa0
7 changed files with 74 additions and 6 deletions

View File

@@ -58,6 +58,17 @@
}
</select>
</div>
@* Task 17: free-text Node filter — exact match against the
SiteCall.SourceNode column. The Source site dropdown narrows
to a site; Node narrows further within that site (or across
sites if Source site is "Any"). *@
<div class="col-auto">
<label class="form-label small mb-1" for="sc-node">Node</label>
<input id="sc-node" type="text" class="form-control form-control-sm"
style="min-width: 150px;" placeholder="Any"
data-test="site-calls-filter-node"
@bind="_nodeFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-from">From</label>
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
@@ -125,6 +136,7 @@
<tr>
<th>Tracked operation</th>
<th>Source site</th>
<th>Node</th>
<th>Channel</th>
<th>Target</th>
<th>Status</th>
@@ -143,6 +155,7 @@
title="Double-click for full detail">
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
<td><span class="small">@SiteName(c.SourceSite)</span></td>
<td><span class="small">@(c.SourceNode ?? "—")</span></td>
<td>@c.Channel</td>
<td>@c.Target</td>
<td>
@@ -253,6 +266,9 @@
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(det.SourceSite)</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">Channel</dt>
<dd class="col-sm-9">@det.Channel</dd>

View File

@@ -82,6 +82,7 @@ public partial class SiteCallsReport
private string _channelFilter = string.Empty;
private string _siteFilter = string.Empty;
private string _targetFilter = string.Empty;
private string _nodeFilter = string.Empty;
private bool _stuckOnly;
private DateTime? _fromFilter;
private DateTime? _toFilter;
@@ -204,7 +205,8 @@ public partial class SiteCallsReport
ToUtc: ToUtc(_toFilter),
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
AfterId: cursor.AfterId,
PageSize: PageSize);
PageSize: PageSize,
SourceNodeFilter: NullIfEmpty(_nodeFilter));
var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success)
@@ -393,6 +395,7 @@ public partial class SiteCallsReport
_channelFilter = string.Empty;
_siteFilter = string.Empty;
_targetFilter = string.Empty;
_nodeFilter = string.Empty;
_stuckOnly = false;
_fromFilter = null;
_toFilter = null;
@@ -403,6 +406,7 @@ public partial class SiteCallsReport
!string.IsNullOrEmpty(_channelFilter) ||
!string.IsNullOrEmpty(_siteFilter) ||
!string.IsNullOrEmpty(_targetFilter) ||
!string.IsNullOrEmpty(_nodeFilter) ||
_stuckOnly ||
_fromFilter != null ||
_toFilter != null;

View File

@@ -33,7 +33,8 @@ public sealed record SiteCallQueryRequest(
DateTime? ToUtc,
DateTime? AfterCreatedAtUtc,
Guid? AfterId,
int PageSize);
int PageSize,
string? SourceNodeFilter = null);
/// <summary>
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
@@ -61,7 +62,8 @@ public sealed record SiteCallSummary(
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
bool IsStuck);
bool IsStuck,
string? SourceNode = null);
/// <summary>
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.

View File

@@ -26,6 +26,11 @@ namespace ScadaLink.Commons.Types.Audit;
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
/// pages with a non-null next cursor.
/// </param>
/// <param name="SourceNode">
/// Restrict to cached calls originating at a specific cluster node (e.g.
/// <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c> means "do not
/// constrain". Rows with NULL <c>SourceNode</c> are excluded when set.
/// </param>
public sealed record SiteCallQueryFilter(
string? Channel = null,
string? SourceSite = null,
@@ -33,4 +38,5 @@ public sealed record SiteCallQueryFilter(
string? Target = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null,
DateTime? StuckCutoffUtc = null);
DateTime? StuckCutoffUtc = null,
string? SourceNode = null);

View File

@@ -204,6 +204,7 @@ SELECT TOP ({paging.PageSize})
FROM dbo.SiteCalls
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode})
AND ({filter.Status} IS NULL OR Status = {filter.Status})
AND ({filter.Target} IS NULL OR Target = {filter.Target})
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})

View File

@@ -232,7 +232,8 @@ public class SiteCallAuditActor : ReceiveActor
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
// keyset cursor, so the page is always honest (full pages, no empty
// pages with a non-null next cursor).
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null);
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null,
SourceNode: NullIfBlank(request.SourceNodeFilter));
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
var paging = new SiteCallPaging(
@@ -633,7 +634,8 @@ public class SiteCallAuditActor : ReceiveActor
CreatedAtUtc: row.CreatedAtUtc,
UpdatedAtUtc: row.UpdatedAtUtc,
TerminalAtUtc: row.TerminalAtUtc,
IsStuck: IsStuck(row, stuckCutoff));
IsStuck: IsStuck(row, stuckCutoff),
SourceNode: row.SourceNode);
}
private static SiteCallDetail ToDetail(SiteCall row)

View File

@@ -223,6 +223,43 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
Assert.Equal(response.SiteCalls[^1].TrackedOperationId, response.NextAfterId);
}
[SkippableFact]
public async Task SiteCallQueryRequest_FilterBySourceNode_ReturnsMatchingSummaries()
{
// Task 17: the new Node filter input pushes a string into
// SiteCallQueryRequest.SourceNodeFilter — the actor must thread it
// onto SiteCallQueryFilter.SourceNode and the response summaries must
// mirror the row's SourceNode column.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo);
var t0 = new DateTime(2026, 5, 20, 14, 0, 0, DateTimeKind.Utc);
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Attempted",
createdAtUtc: t0, sourceNode: "site-plant-a-node-a"));
await repo.UpsertAsync(NewRow(
TrackedOperationId.New(), siteId, status: "Delivered",
createdAtUtc: t0.AddMinutes(1), terminal: true, sourceNode: "site-plant-a-node-b"));
actor.Tell(
new SiteCallQueryRequest(
"corr-node", StatusFilter: null, SourceSiteFilter: siteId, ChannelFilter: null,
TargetKeyword: null, StuckOnly: false, FromUtc: null, ToUtc: null,
AfterCreatedAtUtc: null, AfterId: null, PageSize: 50,
SourceNodeFilter: "site-plant-a-node-a"),
TestActor);
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
Assert.True(response.Success);
Assert.Single(response.SiteCalls);
Assert.Equal("site-plant-a-node-a", response.SiteCalls[0].SourceNode);
Assert.Equal("Attempted", response.SiteCalls[0].Status);
}
[SkippableFact]
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
{