280 lines
9.9 KiB
C#
280 lines
9.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3)
|
|
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the
|
|
/// active <see cref="AuditLogQueryFilter"/> 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 <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. Bundle E (M7-T13) extends
|
|
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
|
/// drill in to <c>?status=Failed</c>. 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();
|
|
}
|
|
}
|
|
|
|
// 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<string>? sites = ParseStringList(query, "site");
|
|
|
|
IReadOnlyList<AuditChannel>? channels = ParseEnumList<AuditChannel>(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<AuditStatus>? statuses = ParseEnumList<AuditStatus>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
|
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
|
/// <c>null</c> when the param is absent or no value parsed.
|
|
/// </summary>
|
|
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(
|
|
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
|
where TEnum : struct, Enum
|
|
{
|
|
if (!query.TryGetValue(key, out var values))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var parsed = new List<TEnum>();
|
|
foreach (var raw in values)
|
|
{
|
|
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
|
{
|
|
parsed.Add(value);
|
|
}
|
|
}
|
|
return parsed.Count > 0 ? parsed : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
|
/// drops blank entries. Returns <c>null</c> when absent or all-blank.
|
|
/// </summary>
|
|
private static IReadOnlyList<string>? ParseStringList(
|
|
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
|
{
|
|
if (!query.TryGetValue(key, out var values))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var parsed = new List<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Built here rather than in markup so the per-row test coverage can
|
|
/// exercise the URL composition without booting the full Blazor renderer.
|
|
/// </remarks>
|
|
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<KeyValuePair<string, string?>>(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);
|
|
}
|
|
}
|