feat(ui): add Node column + filter to AuditLog grid
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -118,6 +118,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
{
|
||||
("OccurredAtUtc", "OccurredAtUtc"),
|
||||
("Site", "Site"),
|
||||
("Node", "Node"),
|
||||
("Channel", "Channel"),
|
||||
("Kind", "Kind"),
|
||||
("Status", "Status"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -175,4 +175,12 @@ public interface IAuditLogRepository
|
||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
|
||||
/// <c>AuditLog</c> 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.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,24 @@ namespace ScadaLink.Commons.Types.Audit;
|
||||
/// <summary>
|
||||
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||
/// Any field left <c>null</c> means "do not constrain on that column". The
|
||||
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
|
||||
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
|
||||
/// list means "do not constrain", and a non-empty list is OR-combined within the
|
||||
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
||||
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
||||
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/>,
|
||||
/// <see cref="SourceSiteIds"/> and <see cref="SourceNodes"/> dimensions are
|
||||
/// multi-value: a <c>null</c> OR empty list means "do not constrain", and a
|
||||
/// non-empty list is OR-combined within the dimension (translated to a SQL
|
||||
/// <c>IN (…)</c>). Time bounds are half-open in the spec sense —
|
||||
/// <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is inclusive of
|
||||
/// the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
|
||||
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
|
||||
/// </summary>
|
||||
/// <param name="SourceNodes">
|
||||
/// Restrict to rows whose <c>SourceNode</c> matches one of the supplied node
|
||||
/// identifiers (e.g. <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). A null
|
||||
/// or empty list means "do not constrain"; a non-empty list is translated to
|
||||
/// SQL <c>SourceNode IN (…)</c>. Rows with NULL <c>SourceNode</c> are excluded
|
||||
/// when the filter is set (the same SourceSiteIds contract).
|
||||
/// </param>
|
||||
public sealed record AuditLogQueryFilter(
|
||||
IReadOnlyList<AuditChannel>? Channels = null,
|
||||
IReadOnlyList<AuditKind>? 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<string>? SourceNodes = null);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct non-null <c>SourceNode</c> values for the Audit Log page's
|
||||
/// Node filter dropdown. EF Core translates this to
|
||||
/// <c>SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode</c>
|
||||
/// — 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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Set<AuditEvent>()
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SourceNode != null)
|
||||
.Select(e => e.SourceNode!)
|
||||
.Distinct()
|
||||
.OrderBy(n => n)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
|
||||
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
|
||||
|
||||
Reference in New Issue
Block a user