feat(ui): AuditFilterBar component (#23 M7)

Adds the filter bar for the central Audit Log page (#23 M7-T2):

* AuditQueryModel — UI binding model with chip-style multi-select state for
  Channel/Kind/Status/Site, a Channel→Kind narrowing map (CachedSubmit and
  CachedResolve appear under both ApiOutbound and DbOutbound per
  Component-AuditLog.md §4), time-range presets (5min/1h/24h/Custom),
  free-text Instance/Script/Target/Actor searches and an Errors-only toggle.
  Collapses to the single-value AuditLogQueryFilter on ToFilter(utcNow);
  multi-select chips take the first selected per dimension and the
  Errors-only toggle pins Failed when Status chips are empty (chip-set wins
  otherwise) — documented Bundle B scope decision.

* AuditFilterBar.razor + .razor.cs — Blazor Server component (Bootstrap
  only, no third-party UI libs). Renders the 10 spec elements plus the
  Errors-only toggle, populates Site chips from ISiteRepository at
  initialisation, exposes [Parameter] EventCallback<AuditLogQueryFilter>
  OnFilterChanged and an optional NowUtcProvider seam for time-window tests.

* AuditFilterBarTests — 5 bUnit tests pinning element presence, Apply
  callback payload, Channel→Kind narrowing, Errors-only toggle precedence
  and the LastHour time-window collapse.
This commit is contained in:
Joseph Doherty
2026-05-20 19:56:49 -04:00
parent 12b86bea7a
commit 13e84a76a7
4 changed files with 602 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Audit
@using ScadaLink.Commons.Types.Enums
@inject ISiteRepository SiteRepository
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@* Channel chip multi-select. *@
<div class="mb-2" data-test="filter-channel">
<label class="form-label small mb-1">Channel</label>
<div>
@foreach (var channel in Enum.GetValues<AuditChannel>())
{
var selected = _model.Channels.Contains(channel);
<button type="button" data-test="chip-channel-@channel"
class="@ChipClass(selected)"
@onclick="() => ToggleChannel(channel)">
@channel
</button>
}
</div>
</div>
@* Kind chip multi-select — narrowed by Channel selection. *@
<div class="mb-2" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
@foreach (var kind in _model.VisibleKinds())
{
var selected = _model.Kinds.Contains(kind);
<button type="button" data-test="chip-kind-@kind"
class="@ChipClass(selected)"
@onclick="() => ToggleKind(kind)">
@kind
</button>
}
</div>
</div>
@* Status chip multi-select. *@
<div class="mb-2" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
@foreach (var status in Enum.GetValues<AuditStatus>())
{
var selected = _model.Statuses.Contains(status);
<button type="button" data-test="chip-status-@status"
class="@ChipClass(selected)"
@onclick="() => ToggleStatus(status)">
@status
</button>
}
</div>
</div>
@* Site chip multi-select — populated from ISiteRepository. *@
<div class="mb-2" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
@if (_sites.Count == 0)
{
<span class="text-muted small">No sites available.</span>
}
else
{
@foreach (var site in _sites)
{
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
<button type="button" data-test="chip-site-@site.SiteIdentifier"
class="@ChipClass(selected)"
@onclick="() => ToggleSite(site.SiteIdentifier)">
@site.Name
</button>
}
}
</div>
</div>
<div class="row g-2 align-items-end">
<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"
@bind="_model.TimeRange">
<option value="@AuditTimeRangePreset.Last5Minutes">Last 5 min</option>
<option value="@AuditTimeRangePreset.LastHour">Last 1h</option>
<option value="@AuditTimeRangePreset.Last24Hours">Last 24h</option>
<option value="@AuditTimeRangePreset.Custom">Custom</option>
</select>
</div>
@* Custom datetime range; only the pickers are conditional, the wrapper is
always emitted so tests can find it. *@
<div class="col-auto" data-test="filter-custom-range">
@if (_model.TimeRange == AuditTimeRangePreset.Custom)
{
<div class="d-flex gap-1 align-items-end">
<div>
<label class="form-label small mb-1" for="audit-from">From (UTC)</label>
<input id="audit-from" type="datetime-local" class="form-control form-control-sm"
@bind="_model.CustomFromUtc" />
</div>
<div>
<label class="form-label small mb-1" for="audit-to">To (UTC)</label>
<input id="audit-to" type="datetime-local" class="form-control form-control-sm"
@bind="_model.CustomToUtc" />
</div>
</div>
}
else
{
<span class="text-muted small">Window: @TimeRangeLabel(_model.TimeRange)</span>
}
</div>
<div class="col-auto" data-test="filter-instance">
<label class="form-label small mb-1" for="audit-instance">Instance</label>
<input id="audit-instance" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.InstanceSearch" />
</div>
<div class="col-auto" data-test="filter-script">
<label class="form-label small mb-1" for="audit-script">Script</label>
<input id="audit-script" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.ScriptSearch" />
</div>
<div class="col-auto" data-test="filter-target">
<label class="form-label small mb-1" for="audit-target">Target</label>
<input id="audit-target" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.TargetSearch" />
</div>
<div class="col-auto" data-test="filter-actor">
<label class="form-label small mb-1" for="audit-actor">Actor</label>
<input id="audit-actor" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.ActorSearch" />
</div>
<div class="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only"
@bind="_model.ErrorsOnly" />
<label class="form-check-label small" for="audit-errors-only">Errors only</label>
</div>
</div>
<div class="col-auto ms-auto">
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
<button class="btn btn-primary btn-sm"
@onclick="Apply" data-test="filter-apply">Apply</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.Components;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
/// plus the Errors-only toggle, and publishes a collapsed
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select →
/// single-value collapse contract.
/// </summary>
public partial class AuditFilterBar
{
private readonly AuditQueryModel _model = new();
private List<Site> _sites = new();
/// <summary>
/// Raised when the user clicks Apply. Carries the collapsed
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary>
[Parameter] public EventCallback<AuditLogQueryFilter> OnFilterChanged { get; set; }
/// <summary>
/// Test seam: overriding "now" is needed to make the time-range collapse tests
/// stable in unit suites. Production callers leave this null and the model
/// uses <see cref="DateTime.UtcNow"/>.
/// </summary>
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
protected override async Task OnInitializedAsync()
{
// Populate the Site chips at component init. Failure is non-fatal — the chip
// section just shows "No sites available." Sites are listed by Name to match
// operator expectations from the Notification Report.
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_sites = sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
catch
{
// Swallowed: filter bar still renders without the Site chips. The page
// surfaces site-load errors elsewhere (the grid query path).
_sites = new();
}
}
private void ToggleChannel(AuditChannel channel)
{
if (!_model.Channels.Add(channel))
{
_model.Channels.Remove(channel);
}
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and
// Kind both picked" coherent — without this, removing a channel could leave
// stale Kind chips selected that no longer match any visible chip.
var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
}
private void ToggleKind(AuditKind kind)
{
if (!_model.Kinds.Add(kind))
{
_model.Kinds.Remove(kind);
}
}
private void ToggleStatus(AuditStatus status)
{
if (!_model.Statuses.Add(status))
{
_model.Statuses.Remove(status);
}
}
private void ToggleSite(string siteIdentifier)
{
if (!_model.SiteIdentifiers.Add(siteIdentifier))
{
_model.SiteIdentifiers.Remove(siteIdentifier);
}
}
private void ClearFilters()
{
_model.Channels.Clear();
_model.Kinds.Clear();
_model.Statuses.Clear();
_model.SiteIdentifiers.Clear();
_model.TimeRange = AuditTimeRangePreset.LastHour;
_model.CustomFromUtc = null;
_model.CustomToUtc = null;
_model.InstanceSearch = string.Empty;
_model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ErrorsOnly = false;
}
private async Task Apply()
{
var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow;
var filter = _model.ToFilter(now);
await OnFilterChanged.InvokeAsync(filter);
}
private static string ChipClass(bool selected) =>
selected
? "btn btn-sm btn-primary me-1 mb-1"
: "btn btn-sm btn-outline-secondary me-1 mb-1";
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
{
AuditTimeRangePreset.Last5Minutes => "now 5 min → now",
AuditTimeRangePreset.LastHour => "now 1h → now",
AuditTimeRangePreset.Last24Hours => "now 24h → now",
_ => "—",
};
}

View File

@@ -0,0 +1,171 @@
using System.Collections.Immutable;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// UI-side binding model for <see cref="AuditFilterBar"/> (#23 M7-T2).
///
/// <para>
/// The model mirrors <see cref="AuditLogQueryFilter"/> but allows multi-select chip
/// state for Channel / Kind / Status / Site (each a <see cref="HashSet{T}"/>) plus
/// extra UI-only fields the underlying filter does not carry: the Errors-only toggle,
/// the time-range preset, and free-text Instance / Script searches.
/// </para>
///
/// <para>
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is single-value
/// per dimension today; the chip multi-selects therefore collapse to the FIRST
/// selected chip when the model is published via <see cref="ToFilter"/>. That is a
/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can
/// either repeat the query per chip or widen the filter contract without rewriting
/// the form. Instance and Script free-text are also UI-only today: the underlying
/// filter has no matching columns, so they are dropped during collapse.
/// </para>
///
/// <para>
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
/// are selected, the collapsed filter pins <see cref="AuditStatus.Failed"/> (the
/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle
/// is a no-op — the explicit Status filter wins.
/// </para>
/// </summary>
public sealed class AuditQueryModel
{
public HashSet<AuditChannel> Channels { get; } = new();
public HashSet<AuditKind> Kinds { get; } = new();
public HashSet<AuditStatus> Statuses { get; } = new();
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
public DateTime? CustomFromUtc { get; set; }
public DateTime? CustomToUtc { get; set; }
public string InstanceSearch { get; set; } = string.Empty;
public string ScriptSearch { get; set; } = string.Empty;
public string TargetSearch { get; set; } = string.Empty;
public string ActorSearch { get; set; } = string.Empty;
public bool ErrorsOnly { get; set; }
/// <summary>
/// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4).
/// <c>CachedSubmit</c> and <c>CachedResolve</c> appear under both
/// <see cref="AuditChannel.ApiOutbound"/> and <see cref="AuditChannel.DbOutbound"/>
/// because the cached-call lifecycle is channel-agnostic at submit/resolve time.
/// Used by the filter bar to narrow the Kind chip list once Channels are picked.
/// </summary>
public static readonly IReadOnlyDictionary<AuditChannel, ImmutableList<AuditKind>> KindsByChannel =
new Dictionary<AuditChannel, ImmutableList<AuditKind>>
{
[AuditChannel.ApiOutbound] = ImmutableList.Create(
AuditKind.ApiCall, AuditKind.ApiCallCached,
AuditKind.CachedSubmit, AuditKind.CachedResolve),
[AuditChannel.DbOutbound] = ImmutableList.Create(
AuditKind.DbWrite, AuditKind.DbWriteCached,
AuditKind.CachedSubmit, AuditKind.CachedResolve),
[AuditChannel.Notification] = ImmutableList.Create(
AuditKind.NotifySend, AuditKind.NotifyDeliver),
[AuditChannel.ApiInbound] = ImmutableList.Create(
AuditKind.InboundRequest, AuditKind.InboundAuthFailure),
};
/// <summary>
/// Returns the kinds visible in the Kind chip list given the currently selected
/// Channels. With no Channel selected, all 10 kinds are visible (no narrowing).
/// With one or more Channels selected, the union of the channel-specific kind
/// lists is returned (deduplicated and order-stable on first-seen).
/// </summary>
public IReadOnlyList<AuditKind> VisibleKinds()
{
if (Channels.Count == 0)
{
return Enum.GetValues<AuditKind>();
}
var seen = new HashSet<AuditKind>();
var result = new List<AuditKind>();
foreach (var ch in Channels)
{
if (!KindsByChannel.TryGetValue(ch, out var kinds))
{
continue;
}
foreach (var k in kinds)
{
if (seen.Add(k))
{
result.Add(k);
}
}
}
return result;
}
/// <summary>
/// Collapses this UI model to the repository's single-value filter.
/// See class doc for the multi-select → single-value contract.
/// </summary>
public AuditLogQueryFilter ToFilter(DateTime utcNow)
{
var status = ResolveStatus();
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
return new AuditLogQueryFilter(
Channel: Channels.Count > 0 ? Channels.First() : null,
Kind: Kinds.Count > 0 ? Kinds.First() : null,
Status: status,
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
FromUtc: fromUtc,
ToUtc: toUtc);
}
private AuditStatus? ResolveStatus()
{
if (Statuses.Count > 0)
{
// Explicit chips win — Errors-only is a no-op.
return Statuses.First();
}
if (ErrorsOnly)
{
// Single-value filter contract: Failed is the lead non-success status.
// When the filter widens to multi-value the full {Failed, Parked, Discarded}
// set will flow through.
return AuditStatus.Failed;
}
return null;
}
private (DateTime? From, DateTime? To) ResolveTimeWindow(DateTime utcNow)
{
return TimeRange switch
{
AuditTimeRangePreset.Last5Minutes => (utcNow.AddMinutes(-5), null),
AuditTimeRangePreset.LastHour => (utcNow.AddHours(-1), null),
AuditTimeRangePreset.Last24Hours => (utcNow.AddHours(-24), null),
AuditTimeRangePreset.Custom => (CustomFromUtc, CustomToUtc),
_ => (null, null),
};
}
}
/// <summary>
/// Time-range presets surfaced in the filter bar. <see cref="Custom"/> reveals the
/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to
/// "now" at the moment Apply is clicked.
/// </summary>
public enum AuditTimeRangePreset
{
Last5Minutes,
LastHour,
Last24Hours,
Custom,
}

View File

@@ -0,0 +1,149 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Components.Audit;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
/// collapse without freezing the clock.
/// </summary>
public class AuditFilterBarTests : BunitContext
{
private readonly ISiteRepository _siteRepo;
public AuditFilterBarTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
}
[Fact]
public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
{
var cut = Render<AuditFilterBar>();
// Each filter element is tagged with a stable data-test attribute so the test
// doesn't churn on cosmetic label changes.
var markers = new[]
{
"data-test=\"filter-channel\"",
"data-test=\"filter-kind\"",
"data-test=\"filter-status\"",
"data-test=\"filter-site\"",
"data-test=\"filter-time-range\"",
"data-test=\"filter-custom-range\"",
"data-test=\"filter-instance\"",
"data-test=\"filter-script\"",
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
{
Assert.Contains(marker, cut.Markup);
}
}
[Fact]
public void Apply_RaisesOnFilterChanged_WithSelectedFilters()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Drive UI: toggle a Channel chip, type in the Target search box, click Apply.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel);
Assert.Equal("Plant-A-OPC", captured.Target);
}
[Fact]
public void Channel_Narrows_Kind_Options_When_Selected()
{
var cut = Render<AuditFilterBar>();
// With no Channel selected, every kind chip is in the DOM.
foreach (var kind in Enum.GetValues<AuditKind>())
{
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
}
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
foreach (var kind in apiKinds)
{
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
}
// Sanity: an unrelated kind is gone.
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
}
[Fact]
public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Toggle Errors-only ON, leaving Status chips empty.
cut.Find("[data-test=\"filter-errors-only\"] input").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Single-value filter contract: Failed leads the non-success set.
Assert.Equal(AuditStatus.Failed, captured!.Status);
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(AuditStatus.Delivered, captured!.Status);
}
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// LastHour is the default preset; clicking Apply must collapse it to FromUtc.
var before = DateTime.UtcNow;
cut.Find("[data-test=\"filter-apply\"]").Click();
var after = DateTime.UtcNow;
Assert.NotNull(captured);
Assert.NotNull(captured!.FromUtc);
// FromUtc should be in [now-1h-eps, now-1h+eps] computed against the Apply moment.
var expectedLow = before.AddHours(-1).AddSeconds(-1);
var expectedHigh = after.AddHours(-1).AddSeconds(1);
Assert.InRange(captured.FromUtc!.Value, expectedLow, expectedHigh);
}
}