using System.Globalization; 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; /// /// 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. 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; } // ?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; } 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 && 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); } /// /// 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(AuditEvent 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); 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.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); } }