feat(ui): AuditLogPage parses query-string filters for drill-ins (#23 M7)

This commit is contained in:
Joseph Doherty
2026-05-20 20:19:47 -04:00
parent ae4480e7aa
commit 450f8bca28
4 changed files with 226 additions and 3 deletions

View File

@@ -32,8 +32,26 @@ public partial class AuditFilterBar
/// </summary>
[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()
{
// 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.

View File

@@ -11,9 +11,12 @@
<div class="container-fluid mt-3">
<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">
<AuditFilterBar OnFilterChanged="HandleFilterChanged" />
<AuditFilterBar OnFilterChanged="HandleFilterChanged"
InitialInstanceSearch="@_initialInstanceSearch" />
</div>
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's

View File

@@ -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 <see cref="HandleRowSelected"/> — Bundle C wires
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
/// 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>
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<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)
{