feat(ui): add Node column + filter to SiteCalls grid
This commit is contained in:
@@ -58,6 +58,17 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="col-auto">
|
||||||
<label class="form-label small mb-1" for="sc-from">From</label>
|
<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"
|
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
|
||||||
@@ -125,6 +136,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Tracked operation</th>
|
<th>Tracked operation</th>
|
||||||
<th>Source site</th>
|
<th>Source site</th>
|
||||||
|
<th>Node</th>
|
||||||
<th>Channel</th>
|
<th>Channel</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -143,6 +155,7 @@
|
|||||||
title="Double-click for full detail">
|
title="Double-click for full detail">
|
||||||
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
|
<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">@SiteName(c.SourceSite)</span></td>
|
||||||
|
<td><span class="small">@(c.SourceNode ?? "—")</span></td>
|
||||||
<td>@c.Channel</td>
|
<td>@c.Channel</td>
|
||||||
<td>@c.Target</td>
|
<td>@c.Target</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -253,6 +266,9 @@
|
|||||||
<dt class="col-sm-3">Source site</dt>
|
<dt class="col-sm-3">Source site</dt>
|
||||||
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
|
<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>
|
<dt class="col-sm-3">Channel</dt>
|
||||||
<dd class="col-sm-9">@det.Channel</dd>
|
<dd class="col-sm-9">@det.Channel</dd>
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public partial class SiteCallsReport
|
|||||||
private string _channelFilter = string.Empty;
|
private string _channelFilter = string.Empty;
|
||||||
private string _siteFilter = string.Empty;
|
private string _siteFilter = string.Empty;
|
||||||
private string _targetFilter = string.Empty;
|
private string _targetFilter = 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;
|
||||||
@@ -204,7 +205,8 @@ public partial class SiteCallsReport
|
|||||||
ToUtc: ToUtc(_toFilter),
|
ToUtc: ToUtc(_toFilter),
|
||||||
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
||||||
AfterId: cursor.AfterId,
|
AfterId: cursor.AfterId,
|
||||||
PageSize: PageSize);
|
PageSize: PageSize,
|
||||||
|
SourceNodeFilter: NullIfEmpty(_nodeFilter));
|
||||||
|
|
||||||
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
||||||
if (response.Success)
|
if (response.Success)
|
||||||
@@ -393,6 +395,7 @@ public partial class SiteCallsReport
|
|||||||
_channelFilter = string.Empty;
|
_channelFilter = string.Empty;
|
||||||
_siteFilter = string.Empty;
|
_siteFilter = string.Empty;
|
||||||
_targetFilter = string.Empty;
|
_targetFilter = string.Empty;
|
||||||
|
_nodeFilter = string.Empty;
|
||||||
_stuckOnly = false;
|
_stuckOnly = false;
|
||||||
_fromFilter = null;
|
_fromFilter = null;
|
||||||
_toFilter = null;
|
_toFilter = null;
|
||||||
@@ -403,6 +406,7 @@ public partial class SiteCallsReport
|
|||||||
!string.IsNullOrEmpty(_channelFilter) ||
|
!string.IsNullOrEmpty(_channelFilter) ||
|
||||||
!string.IsNullOrEmpty(_siteFilter) ||
|
!string.IsNullOrEmpty(_siteFilter) ||
|
||||||
!string.IsNullOrEmpty(_targetFilter) ||
|
!string.IsNullOrEmpty(_targetFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_nodeFilter) ||
|
||||||
_stuckOnly ||
|
_stuckOnly ||
|
||||||
_fromFilter != null ||
|
_fromFilter != null ||
|
||||||
_toFilter != null;
|
_toFilter != null;
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public sealed record SiteCallQueryRequest(
|
|||||||
DateTime? ToUtc,
|
DateTime? ToUtc,
|
||||||
DateTime? AfterCreatedAtUtc,
|
DateTime? AfterCreatedAtUtc,
|
||||||
Guid? AfterId,
|
Guid? AfterId,
|
||||||
int PageSize);
|
int PageSize,
|
||||||
|
string? SourceNodeFilter = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
/// 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 CreatedAtUtc,
|
||||||
DateTime UpdatedAtUtc,
|
DateTime UpdatedAtUtc,
|
||||||
DateTime? TerminalAtUtc,
|
DateTime? TerminalAtUtc,
|
||||||
bool IsStuck);
|
bool IsStuck,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
|
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
||||||
/// pages with a non-null next cursor.
|
/// pages with a non-null next cursor.
|
||||||
/// </param>
|
/// </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(
|
public sealed record SiteCallQueryFilter(
|
||||||
string? Channel = null,
|
string? Channel = null,
|
||||||
string? SourceSite = null,
|
string? SourceSite = null,
|
||||||
@@ -33,4 +38,5 @@ public sealed record SiteCallQueryFilter(
|
|||||||
string? Target = null,
|
string? Target = null,
|
||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null,
|
DateTime? ToUtc = null,
|
||||||
DateTime? StuckCutoffUtc = null);
|
DateTime? StuckCutoffUtc = null,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ SELECT TOP ({paging.PageSize})
|
|||||||
FROM dbo.SiteCalls
|
FROM dbo.SiteCalls
|
||||||
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
|
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
|
||||||
AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
|
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.Status} IS NULL OR Status = {filter.Status})
|
||||||
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
||||||
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
||||||
|
|||||||
@@ -232,7 +232,8 @@ public class SiteCallAuditActor : ReceiveActor
|
|||||||
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
|
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
|
||||||
// keyset cursor, so the page is always honest (full pages, no empty
|
// keyset cursor, so the page is always honest (full pages, no empty
|
||||||
// pages with a non-null next cursor).
|
// 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 pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
|
||||||
var paging = new SiteCallPaging(
|
var paging = new SiteCallPaging(
|
||||||
@@ -633,7 +634,8 @@ public class SiteCallAuditActor : ReceiveActor
|
|||||||
CreatedAtUtc: row.CreatedAtUtc,
|
CreatedAtUtc: row.CreatedAtUtc,
|
||||||
UpdatedAtUtc: row.UpdatedAtUtc,
|
UpdatedAtUtc: row.UpdatedAtUtc,
|
||||||
TerminalAtUtc: row.TerminalAtUtc,
|
TerminalAtUtc: row.TerminalAtUtc,
|
||||||
IsStuck: IsStuck(row, stuckCutoff));
|
IsStuck: IsStuck(row, stuckCutoff),
|
||||||
|
SourceNode: row.SourceNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SiteCallDetail ToDetail(SiteCall row)
|
private static SiteCallDetail ToDetail(SiteCall row)
|
||||||
|
|||||||
@@ -223,6 +223,43 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
Assert.Equal(response.SiteCalls[^1].TrackedOperationId, response.NextAfterId);
|
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]
|
[SkippableFact]
|
||||||
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
|
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user