using System.Globalization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.WebUtilities; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit; /// /// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3) /// wires up AuditFilterBar and AuditResultsGrid: the page owns the /// active and re-pushes a fresh instance to the /// grid on every Apply (the grid uses reference identity as its "reload" /// 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=, ?kind=, and the UI-only /// ?instance= are read on initialization. Bundle E (M7-T13) extends /// this with ?status= so the Health-dashboard Audit error-rate tile can /// drill in to ?status=Failed. The ExecutionId follow-up adds /// ?executionId= for the "View this execution" drill-in, and the /// ParentExecutionId follow-up adds ?parentExecutionId= for the /// "View parent execution" drill-in. 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. /// /// /// /// Query-string filters are re-applied on every , /// not just on init. The drilldown drawer's "View this/parent execution" actions /// navigate to /audit/log?executionId=… while the user is ALREADY on this /// routed page — Blazor treats that as a same-component navigation, so /// does not re-run. Without the /// subscription the URL would /// change but would stay stale and the grid would /// never reload to the new drill-in. The subscription is disposed via /// . /// /// public partial class AuditLogPage : IDisposable { [Inject] private NavigationManager Navigation { get; set; } = null!; private AuditLogQueryFilter? _currentFilter; private AuditEventView? _selectedEvent; private bool _drawerOpen; private string? _initialInstanceSearch; /// protected override void OnInitialized() { ApplyQueryStringFilters(); Navigation.LocationChanged += HandleLocationChanged; } /// /// Re-applies the query-string drill-in filters when the URL changes while /// this page stays routed (e.g. the drawer's "View parent execution" action /// navigates to /audit/log?executionId=…). Reassigning /// to a fresh instance is what kicks the results /// grid into reloading; we also close the drawer so the operator sees the /// newly filtered grid. The body is marshalled through /// because /// can fire off the renderer's /// synchronization context. /// private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) { _ = InvokeAsync(() => { ApplyQueryStringFilters(); _drawerOpen = false; StateHasChanged(); }); } /// Unsubscribes from navigation events to prevent memory leaks when the component is removed. public void Dispose() { Navigation.LocationChanged -= HandleLocationChanged; } private void ApplyQueryStringFilters() { var uri = Navigation.ToAbsoluteUri(Navigation.Uri); var query = QueryHelpers.ParseQuery(uri.Query); // A paramless navigation (e.g. clicking the "Audit Log" nav link while // already here) intentionally preserves the last applied filter rather // than clearing the grid: this method is a drill-in mechanism and every // drill-in carries query params. The operator clears via the filter bar. if (query.Count == 0) { return; } Guid? correlationId = null; if (query.TryGetValue("correlationId", out var corrValues) && Guid.TryParse(corrValues.ToString(), out var parsedCorr)) { correlationId = parsedCorr; } // ?executionId= is the "View this execution" drill-in target — the // universal per-run correlation value. Lax-parsed like ?correlationId=: // an unparseable value is silently dropped (no constraint). Guid? executionId = null; if (query.TryGetValue("executionId", out var execValues) && Guid.TryParse(execValues.ToString(), out var parsedExec)) { executionId = parsedExec; } // ?parentExecutionId= constrains to runs spawned by a given execution. // Lax-parsed like ?executionId=: an unparseable value is silently dropped. Guid? parentExecutionId = null; if (query.TryGetValue("parentExecutionId", out var parentExecValues) && Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec)) { parentExecutionId = parsedParentExec; } 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(); } } // site/channel/kind/status accept repeated params for symmetry with the // multi-value export URL — a single ?site=/?channel=/?kind=/?status= // drill-in still works (one-element list). Unknown enum names are silently // dropped. The lax-parse contract is shared with the two export endpoints // via AuditQueryParamParsers so all three surfaces stay in lockstep. IReadOnlyList? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site")); IReadOnlyList? channels = AuditQueryParamParsers.ParseEnumList(Raw(query, "channel")); // ?kind= is honored for symmetry with BuildExportUrl, which emits a kind= // param — a kind drill-in deep link must round-trip back into the filter. IReadOnlyList? kinds = AuditQueryParamParsers.ParseEnumList(Raw(query, "kind")); // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in // with ?status=Failed (and operators may craft URLs with Parked/Discarded). // Unknown values are silently dropped — the page still renders without // the constraint. IReadOnlyList? statuses = AuditQueryParamParsers.ParseEnumList(Raw(query, "status")); // 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 && executionId is null && parentExecutionId is null && target is null && actor is null && sites is null && channels is null && kinds is null && statuses is null) { return; } _currentFilter = new AuditLogQueryFilter( Channels: channels, Kinds: kinds, Statuses: statuses, SourceSiteIds: sites, Target: target, Actor: actor, CorrelationId: correlationId, ExecutionId: executionId, ParentExecutionId: parentExecutionId); } /// /// Extracts the raw repeated values for one query-string key, returning /// null when the key is absent so the shared /// sees the same absent-vs-present /// distinction the ASP.NET IQueryCollection callers do. /// StringValues is itself an IEnumerable<string?>. /// private static IEnumerable? Raw( Dictionary query, string key) => query.TryGetValue(key, out var values) ? (IEnumerable)values : null; private void HandleFilterChanged(AuditLogQueryFilter filter) { // Always reassign — the grid keys reloads on reference change, so even a // chip-for-chip identical filter must allocate a fresh instance. _currentFilter = filter; } private void HandleRowSelected(AuditEventView row) { // Bundle C: a grid row click hands us the full AuditEvent. We pin it as // the selected row and open the drilldown drawer — the drawer is fully // presentational so we do not need to refetch the row. _selectedEvent = row; _drawerOpen = true; } private void HandleDrawerClose() { // We deliberately keep _selectedEvent set so re-opening (e.g. via the // grid) shows the same row instantly without a re-render flicker. _drawerOpen = false; } /// /// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most /// recently applied filter as query-string params so the server-side /// streaming endpoint reproduces the user's current view. With no filter /// applied yet, returns the bare endpoint — i.e. an unconstrained export. /// /// /// Built here rather than in markup so the per-row test coverage can /// exercise the URL composition without booting the full Blazor renderer. /// internal string ExportUrl => BuildExportUrl(_currentFilter); /// /// Builds the CSV export URL for the given filter, encoding all active filter dimensions as query parameters. /// /// Currently applied filter; null returns the bare export endpoint. internal static string BuildExportUrl(AuditLogQueryFilter? filter) { const string basePath = "/api/centralui/audit/export"; if (filter is null) { return basePath; } // No capacity hint: the dimensions are multi-value, so the part count is // unbounded by the number of filter fields. var parts = new List>(); // Task 9: the filter dimensions are multi-value end-to-end. Emit ONE // repeated query-string key per selected value (channel=A&channel=B); the // export endpoint's ParseFilter reads the full repeated set. if (filter.Channels is { Count: > 0 } channels) { foreach (var channel in channels) { parts.Add(new("channel", channel.ToString())); } } if (filter.Kinds is { Count: > 0 } kinds) { foreach (var kind in kinds) { parts.Add(new("kind", kind.ToString())); } } if (filter.Statuses is { Count: > 0 } statuses) { foreach (var status in statuses) { parts.Add(new("status", status.ToString())); } } if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds) { foreach (var site in sourceSiteIds) { if (!string.IsNullOrWhiteSpace(site)) { parts.Add(new("site", site)); } } } if (!string.IsNullOrWhiteSpace(filter.Target)) { parts.Add(new("target", filter.Target)); } if (!string.IsNullOrWhiteSpace(filter.Actor)) { parts.Add(new("actor", filter.Actor)); } if (filter.CorrelationId is { } corr) { parts.Add(new("correlationId", corr.ToString())); } if (filter.ExecutionId is { } exec) { parts.Add(new("executionId", exec.ToString())); } if (filter.ParentExecutionId is { } parentExec) { parts.Add(new("parentExecutionId", parentExec.ToString())); } if (filter.FromUtc is { } from) { parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); } if (filter.ToUtc is { } to) { parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture))); } if (parts.Count == 0) { return basePath; } return QueryHelpers.AddQueryString(basePath, parts); } }