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

@@ -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
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@@ -58,6 +60,21 @@
</div>
</div>
@* 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. *@
<div class="col-auto" data-test="filter-node">
<label class="form-label small mb-1">Node</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_sourceNodes"
Selected="_model.SourceNodes"
EmptyText="No nodes available"
DataTest="filter-node-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm"

View File

@@ -31,6 +31,15 @@ public partial class AuditFilterBar
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary>
/// Distinct <c>SourceNode</c> identifiers in display order; populated once
/// when the filter bar initialises from the cached
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetDistinctSourceNodesAsync"/>
/// snapshot (60s TTL). Failure is non-fatal — the dropdown falls back to
/// "No nodes available", mirroring the site loader.
/// </summary>
private IReadOnlyList<string> _sourceNodes = Array.Empty<string>();
/// <summary>
/// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> 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<string>();
}
}
/// <summary>
@@ -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;

View File

@@ -38,6 +38,14 @@ public sealed class AuditQueryModel
public HashSet<AuditStatus> Statuses { get; } = new();
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Selected source-node identifiers (e.g. <c>"central-a"</c>,
/// <c>"site-plant-a-node-a"</c>). Mirrors <see cref="SiteIdentifiers"/> —
/// chip multi-select state, empty = "do not constrain", mapped through to
/// <see cref="AuditLogQueryFilter.SourceNodes"/> by <see cref="ToFilter"/>.
/// </summary>
public HashSet<string> 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);
}
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>

View File

@@ -105,6 +105,9 @@
case "Site":
<span class="small">@(row.SourceSiteId ?? "—")</span>
break;
case "Node":
<span class="small">@(row.SourceNode ?? "—")</span>
break;
case "Channel":
<span class="small">@row.Channel</span>
break;

View File

@@ -118,6 +118,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
{
("OccurredAtUtc", "OccurredAtUtc"),
("Site", "Site"),
("Node", "Node"),
("Channel", "Channel"),
("Kind", "Kind"),
("Status", "Status"),

View File

@@ -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<string>? _cachedSourceNodes;
private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
/// <summary>
/// Production constructor — resolves <see cref="IAuditLogRepository"/> 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<IAuditLogRepository>();
return await repository.GetExecutionTreeAsync(executionId, ct);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> 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<string> snapshot;
if (_injectedRepository is not null)
{
snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
}
else
{
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
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;
}
}

View File

@@ -69,4 +69,15 @@ public interface IAuditLogQueryService
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
/// <summary>
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
/// central <c>AuditLog</c> 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.
/// </summary>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}