diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor new file mode 100644 index 0000000..8601e5f --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor @@ -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 + +
+
+ @* Channel chip multi-select. *@ +
+ +
+ @foreach (var channel in Enum.GetValues()) + { + var selected = _model.Channels.Contains(channel); + + } +
+
+ + @* Kind chip multi-select — narrowed by Channel selection. *@ +
+ +
+ @foreach (var kind in _model.VisibleKinds()) + { + var selected = _model.Kinds.Contains(kind); + + } +
+
+ + @* Status chip multi-select. *@ +
+ +
+ @foreach (var status in Enum.GetValues()) + { + var selected = _model.Statuses.Contains(status); + + } +
+
+ + @* Site chip multi-select — populated from ISiteRepository. *@ +
+ +
+ @if (_sites.Count == 0) + { + No sites available. + } + else + { + @foreach (var site in _sites) + { + var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier); + + } + } +
+
+ +
+
+ + +
+ + @* Custom datetime range; only the pickers are conditional, the wrapper is + always emitted so tests can find it. *@ +
+ @if (_model.TimeRange == AuditTimeRangePreset.Custom) + { +
+
+ + +
+
+ + +
+
+ } + else + { + Window: @TimeRangeLabel(_model.TimeRange) + } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs new file mode 100644 index 0000000..40ab3f4 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs @@ -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; + +/// +/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the +/// binding state, renders the 10 filter elements +/// plus the Errors-only toggle, and publishes a collapsed +/// via when the +/// user clicks Apply. See for the multi-select → +/// single-value collapse contract. +/// +public partial class AuditFilterBar +{ + private readonly AuditQueryModel _model = new(); + private List _sites = new(); + + /// + /// Raised when the user clicks Apply. Carries the collapsed + /// the parent page hands to + /// . + /// + [Parameter] public EventCallback OnFilterChanged { get; set; } + + /// + /// 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 . + /// + [Parameter] public Func? 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", + _ => "—", + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs new file mode 100644 index 0000000..6ed9e70 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs @@ -0,0 +1,171 @@ +using System.Collections.Immutable; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// UI-side binding model for (#23 M7-T2). +/// +/// +/// The model mirrors but allows multi-select chip +/// state for Channel / Kind / Status / Site (each a ) 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. +/// +/// +/// +/// The repository filter contract () is single-value +/// per dimension today; the chip multi-selects therefore collapse to the FIRST +/// selected chip when the model is published via . 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. +/// +/// +/// +/// The Errors-only toggle is a convenience: when true AND no explicit Status chips +/// are selected, the collapsed filter pins (the +/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle +/// is a no-op — the explicit Status filter wins. +/// +/// +public sealed class AuditQueryModel +{ + public HashSet Channels { get; } = new(); + public HashSet Kinds { get; } = new(); + public HashSet Statuses { get; } = new(); + public HashSet 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; } + + /// + /// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4). + /// CachedSubmit and CachedResolve appear under both + /// and + /// 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. + /// + public static readonly IReadOnlyDictionary> KindsByChannel = + new Dictionary> + { + [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), + }; + + /// + /// 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). + /// + public IReadOnlyList VisibleKinds() + { + if (Channels.Count == 0) + { + return Enum.GetValues(); + } + + var seen = new HashSet(); + var result = new List(); + 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; + } + + /// + /// Collapses this UI model to the repository's single-value filter. + /// See class doc for the multi-select → single-value contract. + /// + 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), + }; + } +} + +/// +/// Time-range presets surfaced in the filter bar. reveals the +/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to +/// "now" at the moment Apply is clicked. +/// +public enum AuditTimeRangePreset +{ + Last5Minutes, + LastHour, + Last24Hours, + Custom, +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs new file mode 100644 index 0000000..282c49c --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -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; + +/// +/// bUnit tests for (#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 OnFilterChanged +/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip +/// visibility; (4) the Errors-only toggle ORs Failed into Status when +/// Status is otherwise empty; (5) the "Last hour" preset populates +/// FromUtc to roughly an hour before "now" — proves the time-window +/// collapse without freezing the clock. +/// +public class AuditFilterBarTests : BunitContext +{ + private readonly ISiteRepository _siteRepo; + + public AuditFilterBarTests() + { + _siteRepo = Substitute.For(); + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List + { + 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(); + + // 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(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(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(); + + // With no Channel selected, every kind chip is in the DOM. + foreach (var kind in Enum.GetValues()) + { + 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(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(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(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(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); + } +}