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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user