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:
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