diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
index 0c06025..9e0905a 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
@@ -1,8 +1,10 @@
+@using ScadaLink.CentralUI.Services
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Audit
@using ScadaLink.Commons.Types.Enums
@inject ISiteRepository SiteRepository
+@inject IAuditLogQueryService AuditLogQueryService
+ @* Node multi-select. Options are the distinct SourceNode values
+ observed in the AuditLog table; the service-side lookup is cached
+ for 60s so a render of this bar costs at most one DB hit per
+ minute per circuit. *@
+
+
Time range
Site identifiers in display order; rebuilt once when sites load.
private IReadOnlyList _siteIds = Array.Empty();
+ ///
+ /// Distinct SourceNode identifiers in display order; populated once
+ /// when the filter bar initialises from the cached
+ ///
+ /// snapshot (60s TTL). Failure is non-fatal — the dropdown falls back to
+ /// "No nodes available", mirroring the site loader.
+ ///
+ private IReadOnlyList _sourceNodes = Array.Empty();
+
///
/// Raised when the user clicks Apply. Carries the
/// the parent page hands to
@@ -80,6 +89,20 @@ public partial class AuditFilterBar
}
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
+
+ // Populate the Node dropdown alongside the Site dropdown. The service
+ // caches the distinct-nodes lookup for 60s so this never costs more
+ // than one DB hit per minute per circuit; on failure the dropdown
+ // degrades to "No nodes available" like the site loader.
+ try
+ {
+ var nodes = await AuditLogQueryService.GetDistinctSourceNodesAsync();
+ _sourceNodes = nodes.ToArray();
+ }
+ catch
+ {
+ _sourceNodes = Array.Empty();
+ }
}
///
@@ -128,6 +151,7 @@ public partial class AuditFilterBar
_model.Kinds.Clear();
_model.Statuses.Clear();
_model.SiteIdentifiers.Clear();
+ _model.SourceNodes.Clear();
_model.TimeRange = AuditTimeRangePreset.LastHour;
_model.CustomFromUtc = null;
_model.CustomToUtc = null;
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
index a880c0d..3864e5b 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
@@ -38,6 +38,14 @@ public sealed class AuditQueryModel
public HashSet Statuses { get; } = new();
public HashSet SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Selected source-node identifiers (e.g. "central-a" ,
+ /// "site-plant-a-node-a" ). Mirrors —
+ /// chip multi-select state, empty = "do not constrain", mapped through to
+ /// by .
+ ///
+ public HashSet SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase);
+
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
public DateTime? CustomFromUtc { get; set; }
public DateTime? CustomToUtc { get; set; }
@@ -153,7 +161,8 @@ public sealed class AuditQueryModel
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
- ToUtc: toUtc);
+ ToUtc: toUtc,
+ SourceNodes: SourceNodes.Count > 0 ? SourceNodes.ToArray() : null);
}
/// The non-success statuses targeted by the Errors-only toggle.
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
index cde0f35..74b086a 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
@@ -105,6 +105,9 @@
case "Site":
@(row.SourceSiteId ?? "—")
break;
+ case "Node":
+ @(row.SourceNode ?? "—")
+ break;
case "Channel":
@row.Channel
break;
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
index 94c9611..981b714 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs
@@ -118,6 +118,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
{
("OccurredAtUtc", "OccurredAtUtc"),
("Site", "Site"),
+ ("Node", "Node"),
("Channel", "Channel"),
("Kind", "Kind"),
("Status", "Status"),
diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
index 346a8a9..6997060 100644
--- a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
+++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
@@ -38,6 +38,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
// and "% errors over the last hour" as the KPI definition.
private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
+ // Audit Log Node filter (Task 15): the distinct-source-nodes lookup powers
+ // the filter dropdown population. A 60s cache keeps rendering the filter
+ // bar cheap — node membership changes (failover, scaling) surface within
+ // a minute, which is acceptable for a filter affordance.
+ private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60);
+
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
private readonly IServiceScopeFactory? _scopeFactory;
@@ -47,6 +53,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
private readonly ICentralHealthAggregator _healthAggregator;
+ // Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so
+ // a stampede of concurrent filter-bar renders does not turn into N DB hits.
+ private readonly object _sourceNodesLock = new();
+ private IReadOnlyList? _cachedSourceNodes;
+ private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
+
///
/// Production constructor — resolves from a
/// fresh DI scope on every call so each query gets its own
@@ -151,4 +163,42 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
var repository = scope.ServiceProvider.GetRequiredService();
return await repository.GetExecutionTreeAsync(executionId, ct);
}
+
+ ///
+ public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default)
+ {
+ // Fast path: a fresh cache entry served entirely from memory.
+ lock (_sourceNodesLock)
+ {
+ if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc)
+ {
+ return _cachedSourceNodes;
+ }
+ }
+
+ IReadOnlyList snapshot;
+ if (_injectedRepository is not null)
+ {
+ snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
+ }
+ else
+ {
+ await using var scope = _scopeFactory!.CreateAsyncScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+ snapshot = await repository.GetDistinctSourceNodesAsync(ct);
+ }
+
+ // Slow path: replace the cache entry. Concurrent slow paths can race
+ // here — every winner stores a valid snapshot, so the last write wins
+ // and no caller sees stale data. The double-check inside the lock is
+ // deliberately omitted: redundant DB hits during a stampede are rare
+ // (60s TTL) and cheaper than holding the lock across the await.
+ lock (_sourceNodesLock)
+ {
+ _cachedSourceNodes = snapshot;
+ _cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl;
+ }
+
+ return snapshot;
+ }
}
diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
index e802ec5..9cd0125 100644
--- a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
+++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
@@ -69,4 +69,15 @@ public interface IAuditLogQueryService
Task> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
+
+ ///
+ /// Returns the distinct, non-null SourceNode values present in the
+ /// central AuditLog table, backing the Audit Log page's Node
+ /// multi-select filter. The implementation caches the result for ~60s so
+ /// rendering the filter bar never produces more than one DB hit per minute
+ /// per circuit. The cache is process-wide — node membership changes
+ /// (failover, scaling) surface within a minute, which is acceptable for a
+ /// filter affordance.
+ ///
+ Task> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}
diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 13fd96b..159eda1 100644
--- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -175,4 +175,12 @@ public interface IAuditLogRepository
Task> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
+
+ ///
+ /// Returns the distinct, non-null SourceNode values present in the
+ /// AuditLog table, in ascending order. Backs the Audit Log page's
+ /// "Node" multi-select filter dropdown — the Central UI caches the result
+ /// for ~60s so the repository is hit at most once per minute per circuit.
+ ///
+ Task> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}
diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
index b042cc2..4b8ac54 100644
--- a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
+++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
@@ -5,16 +5,24 @@ namespace ScadaLink.Commons.Types.Audit;
///
/// Filter predicate for .
/// Any field left null means "do not constrain on that column". The
-/// , , and
-/// dimensions are multi-value: a null OR empty
-/// list means "do not constrain", and a non-empty list is OR-combined within the
-/// dimension (translated to a SQL IN (…) ). Time bounds are half-open in
-/// the spec sense — is inclusive and is
-/// inclusive of the upper bound; the repository SQL uses >= / <=
+/// , , ,
+/// and dimensions are
+/// multi-value: a null OR empty list means "do not constrain", and a
+/// non-empty list is OR-combined within the dimension (translated to a SQL
+/// IN (…) ). Time bounds are half-open in the spec sense —
+/// is inclusive and is inclusive of
+/// the upper bound; the repository SQL uses >= / <=
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value , and
/// dimensions constrain on equality when set.
///
+///
+/// Restrict to rows whose SourceNode matches one of the supplied node
+/// identifiers (e.g. "central-a" , "site-plant-a-node-a" ). A null
+/// or empty list means "do not constrain"; a non-empty list is translated to
+/// SQL SourceNode IN (…) . Rows with NULL SourceNode are excluded
+/// when the filter is set (the same SourceSiteIds contract).
+///
public sealed record AuditLogQueryFilter(
IReadOnlyList? Channels = null,
IReadOnlyList? Kinds = null,
@@ -26,4 +34,5 @@ public sealed record AuditLogQueryFilter(
Guid? ExecutionId = null,
Guid? ParentExecutionId = null,
DateTime? FromUtc = null,
- DateTime? ToUtc = null);
+ DateTime? ToUtc = null,
+ IReadOnlyList? SourceNodes = null);
diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index 2be5862..beb2d07 100644
--- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -140,6 +140,13 @@ VALUES
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
+ // SourceNode filter mirrors SourceSiteIds: a non-empty list translates to
+ // SQL IN (…); NULL SourceNode rows are excluded when the filter is set.
+ if (filter.SourceNodes is { Count: > 0 } sourceNodes)
+ {
+ query = query.Where(e => e.SourceNode != null && sourceNodes.Contains(e.SourceNode));
+ }
+
if (!string.IsNullOrEmpty(filter.Target))
{
var target = filter.Target;
@@ -761,6 +768,25 @@ VALUES
}
}
+ ///
+ /// Distinct non-null SourceNode values for the Audit Log page's
+ /// Node filter dropdown. EF Core translates this to
+ /// SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode
+ /// — a single index-less scan, but the column is bounded (one entry per
+ /// node in the cluster, currently <10) and the Central UI caches the
+ /// result for ~60s, so a periodic scan is acceptable.
+ ///
+ public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default)
+ {
+ return await _context.Set()
+ .AsNoTracking()
+ .Where(e => e.SourceNode != null)
+ .Select(e => e.SourceNode!)
+ .Distinct()
+ .OrderBy(n => n)
+ .ToListAsync(ct);
+ }
+
///
/// Splits a STRING_AGG comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
index 32a6606..094e4b4 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
@@ -228,5 +228,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
+
+ public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
+ _inner.GetDistinctSourceNodesAsync(ct);
}
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
index 290ff16..aaaa6b6 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
@@ -86,6 +86,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult>(Array.Empty());
+
+ public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
index 4ad28a8..bad4feb 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
@@ -55,6 +55,9 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult>(Array.Empty());
+
+ public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
///
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
index f8b3b49..447feab 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
@@ -101,6 +101,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult>(Array.Empty());
+
+ public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
///
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
index d34fa3d..26617c5 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
@@ -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();
+ _auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any())
+ .Returns(Task.FromResult>(new[] { "central-a", "central-b" }));
+ _auditLogQueryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(Array.Empty()));
+ 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(p => p
+ .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(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()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
index a0549b2..e6dd183 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs
@@ -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
+ {
+ MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
+ });
+
+ var cut = Render(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()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
index f947dd9..f4902a1 100644
--- a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs
@@ -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();
+ var filter = new AuditLogQueryFilter(
+ SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
+ repo.QueryAsync(filter, Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(Array.Empty()));
+
+ var sut = new AuditLogQueryService(repo, EmptyAggregator());
+ await sut.QueryAsync(filter);
+
+ await repo.Received(1).QueryAsync(
+ Arg.Is(f =>
+ f.SourceNodes != null
+ && f.SourceNodes.Count == 2
+ && f.SourceNodes.Contains("central-a")
+ && f.SourceNodes.Contains("site-plant-a-node-a")),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
+ {
+ var repo = Substitute.For();
+ var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
+ repo.GetDistinctSourceNodesAsync(Arg.Any())
+ .Returns(Task.FromResult>(expected));
+
+ var sut = new AuditLogQueryService(repo, EmptyAggregator());
+
+ var result = await sut.GetDistinctSourceNodesAsync();
+
+ Assert.Equal(expected, result);
+ await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any());
+ }
+
+ [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();
+ repo.GetDistinctSourceNodesAsync(Arg.Any())
+ .Returns(Task.FromResult>(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());
+ }
+
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
index 669ff14..ae1c72a 100644
--- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
@@ -297,6 +297,91 @@ public class AuditLogRepositoryTests : IClassFixture
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()
{
diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
index 7db23d2..82cafd0 100644
--- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
+++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
@@ -100,6 +100,9 @@ public class SiteAuditPushFlowTests : TestKit
public Task> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default)
=> throw new NotSupportedException();
+
+ public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default)
+ => throw new NotSupportedException();
}
private static AuditEvent NewPendingEvent(Guid id) => new()