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=, 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. 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(); } } // site/channel/status accept repeated params for symmetry with the // multi-value export URL — a single ?site=/?channel=/?status= drill-in // still works (one-element list). Unknown enum names are silently dropped. IReadOnlyList? sites = ParseStringList(query, "site"); IReadOnlyList? channels = ParseEnumList(query, "channel"); // 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 = ParseEnumList(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 && target is null && actor is null && sites is null && channels is null && statuses is null) { return; } _currentFilter = new AuditLogQueryFilter( Channels: channels, Statuses: statuses, SourceSiteIds: sites, Target: target, Actor: actor, CorrelationId: correlationId); } /// /// Reads EVERY value of a (possibly repeated) query param and parses each as /// , dropping unparseable values silently. Returns /// null when the param is absent or no value parsed. /// private static IReadOnlyList? ParseEnumList( Dictionary query, string key) where TEnum : struct, Enum { if (!query.TryGetValue(key, out var values)) { return null; } var parsed = new List(); foreach (var raw in values) { if (Enum.TryParse(raw, ignoreCase: true, out var value)) { parsed.Add(value); } } return parsed.Count > 0 ? parsed : null; } /// /// Reads EVERY value of a (possibly repeated) query param, trims each, and /// drops blank entries. Returns null when absent or all-blank. /// private static IReadOnlyList? ParseStringList( Dictionary query, string key) { if (!query.TryGetValue(key, out var values)) { return null; } var parsed = new List(); foreach (var raw in values) { if (!string.IsNullOrWhiteSpace(raw)) { parsed.Add(raw.Trim()); } } return parsed.Count > 0 ? parsed : 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; } var parts = new List>(9); // 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.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); } }