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);
}
}