feat(ui): add Node column + filter to AuditLog grid
This commit is contained in:
@@ -228,5 +228,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
_inner.GetExecutionTreeAsync(executionId, ct);
|
||||
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
_inner.GetDistinctSourceNodesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
|
||||
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
|
||||
|
||||
@@ -55,6 +55,9 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -101,6 +101,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,6 +297,91 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
|
||||
// Node multi-select contract — non-empty list → SQL IN (…); NULL
|
||||
// SourceNode rows are excluded when the filter is set; the distinct
|
||||
// enumeration omits nulls and orders ascending.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc);
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: null));
|
||||
|
||||
var rows = await repo.QueryAsync(
|
||||
new AuditLogQueryFilter(
|
||||
SourceSiteIds: new[] { siteId },
|
||||
SourceNodes: new[] { "central-a" }),
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("central-a", rows[0].SourceNode);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 4, 11, 0, 0, DateTimeKind.Utc);
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "site-plant-a-node-a"));
|
||||
|
||||
var rows = await repo.QueryAsync(
|
||||
new AuditLogQueryFilter(
|
||||
SourceSiteIds: new[] { siteId },
|
||||
SourceNodes: new[] { "central-a", "central-b" }),
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" }));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-b"));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-a"));
|
||||
// Duplicate of "central-a" — must collapse to one entry.
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "central-a"));
|
||||
// Null row — must be excluded entirely.
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), sourceNode: null));
|
||||
|
||||
var nodes = await repo.GetDistinctSourceNodesAsync();
|
||||
|
||||
// The whole table is scanned (no per-test scoping on this query), so the
|
||||
// assertion is "our seeded values appear once each, in order" rather
|
||||
// than a strict equality on the full result.
|
||||
Assert.Contains("central-a", nodes);
|
||||
Assert.Contains("central-b", nodes);
|
||||
// No null entry — the WHERE SourceNode IS NOT NULL clause drops them.
|
||||
Assert.DoesNotContain(nodes, n => n is null);
|
||||
// Ordered ascending.
|
||||
Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
|
||||
{
|
||||
|
||||
@@ -100,6 +100,9 @@ public class SiteAuditPushFlowTests : TestKit
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static AuditEvent NewPendingEvent(Guid id) => new()
|
||||
|
||||
Reference in New Issue
Block a user