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

This commit is contained in:
Joseph Doherty
2026-05-23 18:01:36 -04:00
parent 466e1454fe
commit bb29d65a94
19 changed files with 392 additions and 8 deletions

View File

@@ -3,8 +3,11 @@ using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Components.Audit;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
@@ -29,6 +32,7 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
public class AuditFilterBarTests : BunitContext
{
private readonly ISiteRepository _siteRepo;
private readonly IAuditLogQueryService _auditLogQueryService;
public AuditFilterBarTests()
{
@@ -40,6 +44,17 @@ public class AuditFilterBarTests : BunitContext
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
// Task 15: the Node multi-select pulls its options from
// IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub
// returns the two central nodes the cluster uses; individual tests can
// override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...).
_auditLogQueryService = Substitute.For<IAuditLogQueryService>();
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
Services.AddSingleton(_auditLogQueryService);
}
[Fact]
@@ -55,6 +70,7 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-kind\"",
"data-test=\"filter-status\"",
"data-test=\"filter-site\"",
"data-test=\"filter-node\"",
"data-test=\"filter-time-range\"",
"data-test=\"filter-custom-range\"",
"data-test=\"filter-instance\"",
@@ -160,6 +176,30 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]
public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter()
{
// Task 15: the Node filter pulls its option set from
// IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the
// chip selection into AuditLogQueryFilter.SourceNodes.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// The bar marker plus the option checkboxes for the two cluster nodes
// are present after init (the constructor stubs return two nodes).
Assert.Contains("data-test=\"filter-node\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-a\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup);
cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.SourceNodes);
Assert.Equal(new[] { "central-a" }, captured.SourceNodes);
}
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{

View File

@@ -123,6 +123,28 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Render_IncludesNodeColumn_BetweenSiteAndChannel()
{
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
// positioned between Site and Channel.
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
Assert.Contains("data-test=\"col-header-Node\"", cut.Markup);
// The header order must place Node between Site and Channel.
var siteIdx = cut.Markup.IndexOf("data-test=\"col-header-Site\"", StringComparison.Ordinal);
var nodeIdx = cut.Markup.IndexOf("data-test=\"col-header-Node\"", StringComparison.Ordinal);
var channelIdx = cut.Markup.IndexOf("data-test=\"col-header-Channel\"", StringComparison.Ordinal);
Assert.True(siteIdx < nodeIdx, "Node column must follow Site.");
Assert.True(nodeIdx < channelIdx, "Node column must precede Channel.");
}
[Fact]
public void Render_IncludesExecutionIdColumn()
{

View File

@@ -282,6 +282,70 @@ public class AuditLogQueryServiceTests
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
// ─────────────────────────────────────────────────────────────────────────
// Task 15: SourceNode filter forwarding + distinct-nodes service contract.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository()
{
// The Audit Log page's new Node multi-select pushes its chip set into
// AuditLogQueryFilter.SourceNodes; the service must thread it through
// unchanged so the repository's IN-list applies.
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(
SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
repo.QueryAsync(filter, Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
await sut.QueryAsync(filter);
await repo.Received(1).QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceNodes != null
&& f.SourceNodes.Count == 2
&& f.SourceNodes.Contains("central-a")
&& f.SourceNodes.Contains("site-plant-a-node-a")),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
{
var repo = Substitute.For<IAuditLogRepository>();
var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(expected, result);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls()
{
// Two back-to-back calls within the 60s TTL must hit the repository
// exactly once — the filter bar should never produce N DB hits when
// the operator opens it twice in quick succession.
var repo = Substitute.For<IAuditLogRepository>();
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a" }));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var first = await sut.GetDistinctSourceNodesAsync();
var second = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(first, second);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue