diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs index 40ab3f4..ba052d3 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs @@ -32,8 +32,26 @@ public partial class AuditFilterBar /// [Parameter] public Func? NowUtcProvider { get; set; } + /// + /// Bundle D drill-in seam (#23 M7-T10..T12). When set on first render, + /// pre-populates the Instance free-text input. Instance is UI-only — the + /// repository filter contract has no instance column — so this flows in + /// through a separate parameter rather than the + /// the parent page passes to the grid. + /// + [Parameter] public string? InitialInstanceSearch { get; set; } + protected override async Task OnInitializedAsync() { + // One-shot prefill from a drill-in deep link. Subsequent parameter changes + // do NOT overwrite user input — the field is owned by the operator after + // first render. + if (!string.IsNullOrWhiteSpace(InitialInstanceSearch)) + { + _model.InstanceSearch = InitialInstanceSearch.Trim(); + } + + // 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. diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor index f47a807..1aadb03 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -11,9 +11,12 @@

Audit Log

- @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. *@ + @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. + Bundle D (M7-T10..T12) threads a query-string instance prefill through + InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
- +
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 4ecc647..240e837 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -1,5 +1,8 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; namespace ScadaLink.CentralUI.Components.Pages.Audit; @@ -11,12 +14,113 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// trigger). Row clicks land in — Bundle C wires /// this to the drilldown drawer; for now it is a no-op seam so test stubs do /// not error. +/// +/// +/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can +/// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, +/// ?actor=, ?site=, ?channel=, and the UI-only +/// ?instance= are read on initialization. When any param is present we +/// allocate a fresh and assign it to +/// , which kicks the results grid into auto-load +/// without the user clicking Apply. Unknown values (e.g. an invalid enum name) +/// are silently dropped — the page still renders, just without that constraint. +/// /// public partial class AuditLogPage { + [Inject] private NavigationManager Navigation { get; set; } = null!; + private AuditLogQueryFilter? _currentFilter; private AuditEvent? _selectedEvent; private bool _drawerOpen; + private string? _initialInstanceSearch; + + protected override void OnInitialized() + { + ApplyQueryStringFilters(); + } + + private void ApplyQueryStringFilters() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.Count == 0) + { + return; + } + + Guid? correlationId = null; + if (query.TryGetValue("correlationId", out var corrValues) + && Guid.TryParse(corrValues.ToString(), out var parsedCorr)) + { + correlationId = parsedCorr; + } + + string? target = null; + if (query.TryGetValue("target", out var targetValues)) + { + var v = targetValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + target = v.Trim(); + } + } + + string? actor = null; + if (query.TryGetValue("actor", out var actorValues)) + { + var v = actorValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + actor = v.Trim(); + } + } + + string? site = null; + if (query.TryGetValue("site", out var siteValues)) + { + var v = siteValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + site = v.Trim(); + } + } + + AuditChannel? channel = null; + if (query.TryGetValue("channel", out var channelValues) + && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) + { + channel = parsedChannel; + } + + // Instance is UI-only — the filter contract has no matching column, so we + // pass it as a separate seam to the filter bar. + if (query.TryGetValue("instance", out var instanceValues)) + { + var v = instanceValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + _initialInstanceSearch = v.Trim(); + } + } + + // If ANY filter-shaped param was provided, allocate the filter so the grid + // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load + // because the filter contract has no instance column — the user still needs + // to refine + Apply for those. + if (correlationId is null && target is null && actor is null && site is null && channel is null) + { + return; + } + + _currentFilter = new AuditLogQueryFilter( + Channel: channel, + SourceSiteId: site, + Target: target, + Actor: actor, + CorrelationId: correlationId); + } private void HandleFilterChanged(AuditLogQueryFilter filter) { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 6d5c07b..9852eb9 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -1,11 +1,15 @@ using System.Security.Claims; using Bunit; +using Bunit.TestDoubles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; using ScadaLink.Security; using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage; using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; @@ -32,6 +36,13 @@ public class AuditLogPageScaffoldTests : BunitContext } private IRenderedComponent RenderAuditLogPage(params string[] roles) + { + return RenderAuditLogPageWithQuery(query: null, roles: roles); + } + + private IAuditLogQueryService _queryService = Substitute.For(); + + private IRenderedComponent RenderAuditLogPageWithQuery(string? query, params string[] roles) { var user = BuildPrincipal(roles); Services.AddSingleton(new TestAuthStateProvider(user)); @@ -42,7 +53,14 @@ public class AuditLogPageScaffoldTests : BunitContext // ISiteRepository and IAuditLogQueryService respectively (Bundle B). // Provide stand-ins so the scaffold smoke tests still render the page. Services.AddSingleton(Substitute.For()); - Services.AddSingleton(Substitute.For()); + Services.AddSingleton(_queryService); + + if (!string.IsNullOrEmpty(query)) + { + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/audit/log?{query}"); + } + return Render(); } @@ -112,4 +130,84 @@ public class AuditLogPageScaffoldTests : BunitContext "Audit section header must precede the Audit Log link."); }); } + + // ───────────────────────────────────────────────────────────────────────── + // Bundle D — query-string drill-in parsing (#23 M7-T10..T12) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads() + { + var corr = Guid.Parse("11111111-2222-3333-4444-555555555555"); + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin"); + + cut.WaitForAssertion(() => + { + // Auto-load fires because correlationId is a real filter dimension. + _queryService.Received().QueryAsync( + Arg.Is(f => f.CorrelationId == corr), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithTargetParam_AppliesTargetFilter() + { + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.Target == "ExternalSystem-Alpha"), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithSiteParam_AppliesSiteFilter() + { + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.SourceSiteId == "plant-a"), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad() + { + _queryService = Substitute.For(); + + var cut = RenderAuditLogPage("Admin"); + + // The grid is in "no filter" state — the page heading renders, but the + // query service must NOT be hit because nothing told us to load. + cut.WaitForAssertion(() => + { + Assert.Contains("Audit Log", cut.Markup); + }); + + _queryService.DidNotReceive().QueryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } }