refactor(audit): consolidate query-param parsers; widen CLI export to multi-value

This commit is contained in:
Joseph Doherty
2026-05-21 05:37:06 -04:00
parent 2a76be1f94
commit f64a7aed02
9 changed files with 350 additions and 177 deletions

View File

@@ -19,7 +19,7 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <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>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</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
@@ -80,18 +80,27 @@ public partial class AuditLogPage
}
}
// 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");
// 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<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
IReadOnlyList<AuditChannel>? channels = ParseEnumList<AuditChannel>(query, "channel");
IReadOnlyList<AuditChannel>? channels =
AuditQueryParamParsers.ParseEnumList<AuditChannel>(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<AuditKind>? kinds =
AuditQueryParamParsers.ParseEnumList<AuditKind>(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<AuditStatus>? statuses = ParseEnumList<AuditStatus>(query, "status");
IReadOnlyList<AuditStatus>? statuses =
AuditQueryParamParsers.ParseEnumList<AuditStatus>(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.
@@ -108,13 +117,15 @@ public partial class AuditLogPage
// 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)
if (correlationId 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,
@@ -123,52 +134,15 @@ public partial class AuditLogPage
}
/// <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.
/// Extracts the raw repeated values for one query-string key, returning
/// <c>null</c> when the key is absent so the shared
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
/// <c>StringValues</c> is itself an <c>IEnumerable&lt;string?&gt;</c>.
/// </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 static IEnumerable<string?>? Raw(
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
private void HandleFilterChanged(AuditLogQueryFilter filter)
{
@@ -213,7 +187,9 @@ public partial class AuditLogPage
return basePath;
}
var parts = new List<KeyValuePair<string, string?>>(9);
// No capacity hint: the dimensions are multi-value, so the part count is
// unbounded by the number of filter fields.
var parts = new List<KeyValuePair<string, string?>>();
// 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.