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:
156
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
Normal file
156
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
Normal 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>
|
||||
126
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs
Normal file
126
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs
Normal 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",
|
||||
_ => "—",
|
||||
};
|
||||
}
|
||||
171
src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
Normal file
171
src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user