feat(ui): AuditLogPage parses query-string filters for drill-ins (#23 M7)
This commit is contained in:
@@ -32,8 +32,26 @@ public partial class AuditFilterBar
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
|
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="AuditLogQueryFilter"/>
|
||||||
|
/// the parent page passes to the grid.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? InitialInstanceSearch { get; set; }
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
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
|
// 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
|
// section just shows "No sites available." Sites are listed by Name to match
|
||||||
// operator expectations from the Notification Report.
|
// operator expectations from the Notification Report.
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h1 class="h4 mb-3">Audit Log</h1>
|
<h1 class="h4 mb-3">Audit Log</h1>
|
||||||
|
|
||||||
@* 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. *@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<AuditFilterBar OnFilterChanged="HandleFilterChanged" />
|
<AuditFilterBar OnFilterChanged="HandleFilterChanged"
|
||||||
|
InitialInstanceSearch="@_initialInstanceSearch" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
|
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||||
|
|
||||||
@@ -11,12 +14,113 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
|||||||
/// trigger). Row clicks land in <see cref="HandleRowSelected"/> — Bundle C wires
|
/// trigger). Row clicks land in <see cref="HandleRowSelected"/> — Bundle C wires
|
||||||
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
|
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
|
||||||
/// not error.
|
/// not error.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
||||||
|
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
||||||
|
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only
|
||||||
|
/// <c>?instance=</c> are read on initialization. When any param is present we
|
||||||
|
/// allocate a fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||||
|
/// <see cref="_currentFilter"/>, 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.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditLogPage
|
public partial class AuditLogPage
|
||||||
{
|
{
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
private AuditLogQueryFilter? _currentFilter;
|
private AuditLogQueryFilter? _currentFilter;
|
||||||
private AuditEvent? _selectedEvent;
|
private AuditEvent? _selectedEvent;
|
||||||
private bool _drawerOpen;
|
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<AuditChannel>(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)
|
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
|
using Bunit.TestDoubles;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ScadaLink.CentralUI.Services;
|
using ScadaLink.CentralUI.Services;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Security;
|
using ScadaLink.Security;
|
||||||
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
|
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
|
||||||
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;
|
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;
|
||||||
@@ -32,6 +36,13 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
||||||
|
{
|
||||||
|
return RenderAuditLogPageWithQuery(query: null, roles: roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
|
||||||
|
private IRenderedComponent<AuditLogPage> RenderAuditLogPageWithQuery(string? query, params string[] roles)
|
||||||
{
|
{
|
||||||
var user = BuildPrincipal(roles);
|
var user = BuildPrincipal(roles);
|
||||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
@@ -42,7 +53,14 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
// ISiteRepository and IAuditLogQueryService respectively (Bundle B).
|
// ISiteRepository and IAuditLogQueryService respectively (Bundle B).
|
||||||
// Provide stand-ins so the scaffold smoke tests still render the page.
|
// Provide stand-ins so the scaffold smoke tests still render the page.
|
||||||
Services.AddSingleton(Substitute.For<ScadaLink.Commons.Interfaces.Repositories.ISiteRepository>());
|
Services.AddSingleton(Substitute.For<ScadaLink.Commons.Interfaces.Repositories.ISiteRepository>());
|
||||||
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
Services.AddSingleton(_queryService);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(query))
|
||||||
|
{
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
nav.NavigateTo($"/audit/log?{query}");
|
||||||
|
}
|
||||||
|
|
||||||
return Render<AuditLogPage>();
|
return Render<AuditLogPage>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +130,84 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
"Audit section header must precede the Audit Log link.");
|
"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<IAuditLogQueryService>();
|
||||||
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
// Auto-load fires because correlationId is a real filter dimension.
|
||||||
|
_queryService.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f => f.CorrelationId == corr),
|
||||||
|
Arg.Any<AuditLogPaging?>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||||
|
{
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
_queryService.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f => f.Target == "ExternalSystem-Alpha"),
|
||||||
|
Arg.Any<AuditLogPaging?>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithSiteParam_AppliesSiteFilter()
|
||||||
|
{
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
_queryService.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"),
|
||||||
|
Arg.Any<AuditLogPaging?>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
||||||
|
{
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
|
||||||
|
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<AuditLogQueryFilter>(),
|
||||||
|
Arg.Any<AuditLogPaging?>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user